initial commit

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

View File

@ -0,0 +1,21 @@
package id.iptek.utms;
import java.time.ZoneId;
import java.util.Locale;
import java.util.TimeZone;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching;
@EnableCaching
@SpringBootApplication
public class UtmsNgBeApplication {
public static void main(String[] args) {
TimeZone.setDefault(TimeZone.getTimeZone(ZoneId.of("Asia/Jakarta")));
Locale.setDefault(Locale.forLanguageTag("en-US"));
SpringApplication.run(UtmsNgBeApplication.class, args);
}
}

View File

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

View File

@ -0,0 +1,62 @@
package id.iptek.utms.api;
import id.iptek.utms.core.audit.dto.AuditTrailResponse;
import id.iptek.utms.core.audit.domain.AuditTrail;
import id.iptek.utms.core.audit.service.AuditTrailService;
import id.iptek.utms.core.i18n.MessageResolver;
import id.iptek.utms.tenant.TenantContext;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/api/audit")
@SecurityRequirement(name = "bearerAuth")
public class AuditController {
private final AuditTrailService auditTrailService;
private final MessageResolver messageResolver;
public AuditController(AuditTrailService auditTrailService, MessageResolver messageResolver) {
this.auditTrailService = auditTrailService;
this.messageResolver = messageResolver;
}
@GetMapping
@PreAuthorize("hasRole('ADMIN')")
public ApiResponse<List<AuditTrailResponse>> listRecent(@RequestParam(defaultValue = "50") int limit) {
String tenantId = TenantContext.getRequiredTenantId();
List<AuditTrail> trails = auditTrailService.listRecent(tenantId, limit);
return ApiResponse.ok(
messageResolver.get("audit.list.success"),
trails.stream().map(this::toResponse).toList()
);
}
private AuditTrailResponse toResponse(AuditTrail trail) {
return new AuditTrailResponse(
trail.getId(),
trail.getTenantId(),
trail.getActor(),
trail.getCorrelationId(),
trail.getAction(),
trail.getDomain(),
trail.getResourceType(),
trail.getResourceId(),
trail.getOutcome(),
trail.getHttpMethod(),
trail.getRequestPath(),
trail.getErrorMessage(),
trail.getBeforeState(),
trail.getAfterState(),
trail.getDetails(),
trail.getCreatedAt()
);
}
}

View File

@ -0,0 +1,50 @@
package id.iptek.utms.api;
import id.iptek.utms.auth.dto.CreateRoleManagementRequest;
import id.iptek.utms.auth.dto.UpdateRolePermissionsRequest;
import id.iptek.utms.auth.service.UserRoleManagementService;
import id.iptek.utms.core.i18n.MessageResolver;
import id.iptek.utms.workflow.dto.ApprovalResponse;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/roles")
@SecurityRequirement(name = "bearerAuth")
public class RoleController {
private final UserRoleManagementService userRoleManagementService;
private final MessageResolver messageResolver;
public RoleController(UserRoleManagementService userRoleManagementService,
MessageResolver messageResolver) {
this.userRoleManagementService = userRoleManagementService;
this.messageResolver = messageResolver;
}
@PostMapping("/management/requests/create")
@PreAuthorize("hasAuthority('ROLE_MANAGE') or hasRole('USER_ROLE_ADMIN')")
public ApiResponse<ApprovalResponse> create(@Valid @RequestBody CreateRoleManagementRequest request,
HttpServletRequest servletRequest) {
return ApiResponse.ok(
messageResolver.get("role.management.request.created"),
userRoleManagementService.submitCreateRoleRequest(request, servletRequest)
);
}
@PostMapping("/management/requests/update-permissions")
@PreAuthorize("hasAuthority('ROLE_MANAGE') or hasRole('USER_ROLE_ADMIN')")
public ApiResponse<ApprovalResponse> updatePermissions(@Valid @RequestBody UpdateRolePermissionsRequest request,
HttpServletRequest servletRequest) {
return ApiResponse.ok(
messageResolver.get("role.management.request.created"),
userRoleManagementService.submitUpdateRolePermissionsRequest(request, servletRequest)
);
}
}

View File

@ -0,0 +1,31 @@
package id.iptek.utms.api;
import id.iptek.utms.core.i18n.MessageResolver;
import id.iptek.utms.tenant.TenantContext;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@RestController
@RequestMapping("/api/tenant")
@SecurityRequirement(name = "bearerAuth")
public class TenantController {
private final MessageResolver messageResolver;
public TenantController(MessageResolver messageResolver) {
this.messageResolver = messageResolver;
}
@GetMapping("/context")
@PreAuthorize("isAuthenticated()")
public ApiResponse<Map<String, String>> tenantContext() {
return ApiResponse.ok("Tenant context resolved",
Map.of("tenantId", TenantContext.getRequiredTenantId()));
}
}

View File

@ -0,0 +1,136 @@
package id.iptek.utms.api;
import id.iptek.utms.auth.dto.CreateUserManagementRequest;
import id.iptek.utms.auth.dto.CurrentUserResponse;
import id.iptek.utms.auth.dto.UpdateUserRolesRequest;
import id.iptek.utms.auth.service.UserRoleManagementService;
import id.iptek.utms.auth.service.UserService;
import id.iptek.utms.core.exception.AppException;
import id.iptek.utms.core.i18n.MessageResolver;
import id.iptek.utms.preference.dto.TablePreferenceProfile;
import id.iptek.utms.preference.dto.TablePreferenceRequest;
import id.iptek.utms.preference.dto.TablePreferenceSavedProfile;
import id.iptek.utms.preference.dto.UserUiPreferencesResponse;
import id.iptek.utms.preference.service.UserPreferenceService;
import id.iptek.utms.tenant.TenantContext;
import id.iptek.utms.tenant.TenantFilter;
import id.iptek.utms.workflow.dto.ApprovalResponse;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Objects;
@RestController
@RequestMapping("/api/users")
@SecurityRequirement(name = "bearerAuth")
public class UserController {
private final UserService userService;
private final UserRoleManagementService userRoleManagementService;
private final UserPreferenceService userPreferenceService;
private final MessageResolver messageResolver;
public UserController(UserService userService,
UserRoleManagementService userRoleManagementService,
UserPreferenceService userPreferenceService,
MessageResolver messageResolver) {
this.userService = userService;
this.userRoleManagementService = userRoleManagementService;
this.userPreferenceService = userPreferenceService;
this.messageResolver = messageResolver;
}
@GetMapping("/me")
@PreAuthorize("hasAuthority('USER_READ') or hasRole('ADMIN')")
public ApiResponse<CurrentUserResponse> me(Authentication authentication) {
CurrentUserResponse response = userService.me(authentication.getName());
return ApiResponse.ok(messageResolver.get("user.me.success"), response);
}
@GetMapping("/preferences")
@PreAuthorize("hasAuthority('USER_READ') or hasRole('USER') or isAuthenticated()")
public ApiResponse<UserUiPreferencesResponse> getPreferences(
@RequestHeader(value = TenantFilter.TENANT_HEADER, required = false) String tenantId,
Authentication authentication) {
requireTenantHeader(tenantId);
return ApiResponse.ok(messageResolver.get("user.preferences.get.success"),
userPreferenceService.getAll(authentication));
}
@PutMapping("/preferences/table")
@PreAuthorize("hasAuthority('USER_READ') or hasRole('USER') or isAuthenticated()")
public ApiResponse<TablePreferenceSavedProfile> upsertTablePreference(
@RequestHeader(value = TenantFilter.TENANT_HEADER, required = false) String tenantId,
Authentication authentication,
@Valid @RequestBody TablePreferenceRequest request) {
requireTenantHeader(tenantId);
return ApiResponse.ok(messageResolver.get("user.preferences.upsert.success"),
userPreferenceService.upsert(authentication, request));
}
@DeleteMapping("/preferences/table/{preferenceKey}")
@PreAuthorize("hasAuthority('USER_READ') or hasRole('USER') or isAuthenticated()")
public ApiResponse<TablePreferenceProfile> resetTablePreference(
@RequestHeader(value = TenantFilter.TENANT_HEADER, required = false) String tenantId,
Authentication authentication,
@PathVariable String preferenceKey) {
requireTenantHeader(tenantId);
return ApiResponse.ok(messageResolver.get("user.preferences.reset.table.success"),
userPreferenceService.resetTablePreference(authentication, preferenceKey));
}
@DeleteMapping("/preferences")
@PreAuthorize("hasAuthority('USER_READ') or hasRole('USER') or isAuthenticated()")
public ApiResponse<Void> resetAllPreferences(
@RequestHeader(value = TenantFilter.TENANT_HEADER, required = false) String tenantId,
Authentication authentication) {
requireTenantHeader(tenantId);
userPreferenceService.resetAll(authentication);
return ApiResponse.ok(messageResolver.get("user.preferences.reset.all.success"), null);
}
@PostMapping("/management/requests/create")
@PreAuthorize("hasAuthority('USER_MANAGE') or hasRole('USER_ROLE_ADMIN')")
public ApiResponse<ApprovalResponse> create(@Valid @RequestBody CreateUserManagementRequest request,
HttpServletRequest servletRequest) {
return ApiResponse.ok(
messageResolver.get("user.management.request.created"),
userRoleManagementService.submitCreateUserRequest(request, servletRequest)
);
}
@PostMapping("/management/requests/update-roles")
@PreAuthorize("hasAuthority('USER_MANAGE') or hasRole('USER_ROLE_ADMIN')")
public ApiResponse<ApprovalResponse> updateRoles(@Valid @RequestBody UpdateUserRolesRequest request,
HttpServletRequest servletRequest) {
return ApiResponse.ok(
messageResolver.get("user.management.request.created"),
userRoleManagementService.submitUpdateUserRolesRequest(request, servletRequest)
);
}
private void requireTenantHeader(String tenantId) {
if (tenantId == null || tenantId.isBlank()) {
throw new AppException(messageResolver.get("tenant.header.required"));
}
String contextTenant = TenantContext.getTenantId();
if (contextTenant == null || contextTenant.isBlank()) {
throw new AppException(messageResolver.get("tenant.header.required"));
}
if (!Objects.equals(tenantId, contextTenant)) {
throw new AppException(messageResolver.get("tenant.header.mismatch"));
}
}
}

View File

@ -0,0 +1,12 @@
package id.iptek.utms.auth.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "app.jwt")
public record JwtProperties(
String secret,
long accessTokenMinutes,
long refreshTokenDays
) {
}

View File

@ -0,0 +1,45 @@
package id.iptek.utms.auth.config;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.ldap.DefaultSpringSecurityContextSource;
import org.springframework.security.ldap.authentication.BindAuthenticator;
import org.springframework.security.ldap.authentication.LdapAuthenticationProvider;
import org.springframework.security.ldap.search.FilterBasedLdapUserSearch;
import org.springframework.security.ldap.userdetails.LdapUserDetailsMapper;
@Configuration
@EnableConfigurationProperties(LdapProperties.class)
public class LdapAuthConfig {
@Bean
@ConditionalOnProperty(name = "app.ldap.enabled", havingValue = "true")
public DefaultSpringSecurityContextSource ldapContextSource(LdapProperties ldapProperties) {
DefaultSpringSecurityContextSource contextSource = new DefaultSpringSecurityContextSource(ldapProperties.url());
contextSource.setBase(ldapProperties.base());
contextSource.setUserDn(ldapProperties.managerDn());
contextSource.setPassword(ldapProperties.managerPassword());
contextSource.afterPropertiesSet();
return contextSource;
}
@Bean(name = "ldapAuthenticationProvider")
@ConditionalOnProperty(name = "app.ldap.enabled", havingValue = "true")
public AuthenticationProvider ldapAuthenticationProvider(DefaultSpringSecurityContextSource ldapContextSource,
LdapProperties ldapProperties) {
BindAuthenticator bindAuthenticator = new BindAuthenticator(ldapContextSource);
FilterBasedLdapUserSearch userSearch = new FilterBasedLdapUserSearch(
ldapProperties.userSearchBase(),
ldapProperties.userSearchFilter(),
ldapContextSource
);
bindAuthenticator.setUserSearch(userSearch);
LdapAuthenticationProvider provider = new LdapAuthenticationProvider(bindAuthenticator);
provider.setUserDetailsContextMapper(new LdapUserDetailsMapper());
return provider;
}
}

View File

@ -0,0 +1,17 @@
package id.iptek.utms.auth.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "app.ldap")
public record LdapProperties(
boolean enabled,
String url,
String base,
String managerDn,
String managerPassword,
String userSearchBase,
String userSearchFilter,
String groupSearchBase,
String groupSearchFilter
) {
}

View File

@ -0,0 +1,86 @@
package id.iptek.utms.auth.config;
import id.iptek.utms.auth.config.LdapProperties;
import id.iptek.utms.auth.security.JwtAuthenticationFilter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
@EnableMethodSecurity
@EnableConfigurationProperties(JwtProperties.class)
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
private final UserDetailsService userDetailsService;
private final AuthenticationProvider ldapAuthenticationProvider;
private final LdapProperties ldapProperties;
public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter,
UserDetailsService userDetailsService,
@Autowired(required = false) @Qualifier("ldapAuthenticationProvider") AuthenticationProvider ldapAuthenticationProvider,
LdapProperties ldapProperties) {
this.jwtAuthenticationFilter = jwtAuthenticationFilter;
this.userDetailsService = userDetailsService;
this.ldapAuthenticationProvider = ldapAuthenticationProvider;
this.ldapProperties = ldapProperties;
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.authenticationProvider(authenticationProvider());
if (ldapProperties.enabled() && ldapAuthenticationProvider != null) {
http.authenticationProvider(ldapAuthenticationProvider);
}
http
.csrf(csrf -> csrf.disable())
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/actuator/health").permitAll()
.requestMatchers(
"/v3/api-docs/**",
"/swagger-ui/**",
"/swagger-ui.html"
).permitAll()
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public DaoAuthenticationProvider authenticationProvider() {
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
provider.setUserDetailsService(userDetailsService);
provider.setPasswordEncoder(passwordEncoder());
return provider;
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
return configuration.getAuthenticationManager();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}

View File

@ -0,0 +1,52 @@
package id.iptek.utms.auth.controller;
import id.iptek.utms.api.ApiResponse;
import id.iptek.utms.auth.dto.AuthTokenResponse;
import id.iptek.utms.auth.dto.LoginRequest;
import id.iptek.utms.auth.dto.RefreshRequest;
import id.iptek.utms.auth.service.AuthService;
import id.iptek.utms.core.i18n.MessageResolver;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import org.springframework.http.HttpHeaders;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/auth")
@Tag(name = "Authentication")
public class AuthController {
private final AuthService authService;
private final MessageResolver messageResolver;
public AuthController(AuthService authService, MessageResolver messageResolver) {
this.authService = authService;
this.messageResolver = messageResolver;
}
@PostMapping("/login")
@Operation(summary = "Login", description = "Returns access and refresh token")
public ApiResponse<AuthTokenResponse> login(@Valid @RequestBody LoginRequest request) {
return ApiResponse.ok(messageResolver.get("auth.login.success"), authService.login(request));
}
@PostMapping("/refresh")
@Operation(summary = "Refresh token")
public ApiResponse<AuthTokenResponse> refresh(@Valid @RequestBody RefreshRequest request) {
return ApiResponse.ok(messageResolver.get("auth.refresh.success"), authService.refresh(request));
}
@PostMapping("/logout")
@PreAuthorize("isAuthenticated()")
@Operation(summary = "Logout and blacklist token")
public ApiResponse<Void> logout(HttpServletRequest request) {
String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION);
String token = authHeader != null && authHeader.startsWith("Bearer ") ? authHeader.substring(7) : null;
authService.logout(token);
return ApiResponse.ok(messageResolver.get("auth.logout.success"), null);
}
}

View File

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

View File

@ -0,0 +1,37 @@
package id.iptek.utms.auth.domain;
import id.iptek.utms.core.domain.BaseEntity;
import id.iptek.utms.core.domain.TenantEntityListener;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import org.hibernate.annotations.Filter;
import java.util.HashSet;
import java.util.Set;
import java.util.UUID;
@Getter
@Setter
@Entity
@EntityListeners(TenantEntityListener.class)
@Filter(name = "tenantFilter", condition = "tenant_id = :tenantId")
@Table(name = "sec_permissions", uniqueConstraints = {
@UniqueConstraint(name = "sec_uk_permissions_tenant_code", columnNames = {"tenant_id", "code"})
})
public class Permission extends BaseEntity {
@Id
@GeneratedValue
private UUID id;
@Column(nullable = false)
private String code;
@Column(nullable = false)
private String name;
@ManyToMany(mappedBy = "permissions")
private Set<Role> roles = new HashSet<>();
}

View File

@ -0,0 +1,40 @@
package id.iptek.utms.auth.domain;
import id.iptek.utms.core.domain.BaseEntity;
import id.iptek.utms.core.domain.TenantEntityListener;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import org.hibernate.annotations.Filter;
import java.time.Instant;
import java.util.UUID;
@Getter
@Setter
@Entity
@EntityListeners(TenantEntityListener.class)
@Filter(name = "tenantFilter", condition = "tenant_id = :tenantId")
@Table(name = "sec_refresh_tokens", indexes = {
@Index(name = "sec_idx_refresh_token", columnList = "token", unique = true)
})
public class RefreshToken extends BaseEntity {
@Id
@GeneratedValue
private UUID id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "user_id", nullable = false)
private User user;
@Column(nullable = false, unique = true, length = 512)
private String token;
@Column(name = "expires_at", nullable = false)
private Instant expiresAt;
@Column(nullable = false)
private boolean revoked;
}

View File

@ -0,0 +1,45 @@
package id.iptek.utms.auth.domain;
import id.iptek.utms.core.domain.BaseEntity;
import id.iptek.utms.core.domain.TenantEntityListener;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import org.hibernate.annotations.Filter;
import java.util.HashSet;
import java.util.Set;
import java.util.UUID;
@Getter
@Setter
@Entity
@EntityListeners(TenantEntityListener.class)
@Filter(name = "tenantFilter", condition = "tenant_id = :tenantId")
@Table(name = "sec_roles", uniqueConstraints = {
@UniqueConstraint(name = "sec_uk_roles_tenant_code", columnNames = {"tenant_id", "code"})
})
public class Role extends BaseEntity {
@Id
@GeneratedValue
private UUID id;
@Column(nullable = false)
private String code;
@Column(nullable = false)
private String name;
@ManyToMany(fetch = FetchType.EAGER)
@JoinTable(
name = "sec_role_permissions",
joinColumns = @JoinColumn(name = "role_id"),
inverseJoinColumns = @JoinColumn(name = "permission_id")
)
private Set<Permission> permissions = new HashSet<>();
@ManyToMany(mappedBy = "roles")
private Set<User> users = new HashSet<>();
}

View File

@ -0,0 +1,55 @@
package id.iptek.utms.auth.domain;
import id.iptek.utms.core.domain.BaseEntity;
import id.iptek.utms.core.domain.TenantEntityListener;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import org.hibernate.annotations.Filter;
import org.hibernate.annotations.FilterDef;
import org.hibernate.annotations.ParamDef;
import java.util.HashSet;
import java.util.Set;
import java.util.UUID;
@Getter
@Setter
@Entity
@EntityListeners(TenantEntityListener.class)
@FilterDef(name = "tenantFilter", parameters = @ParamDef(name = "tenantId", type = String.class))
@Filter(name = "tenantFilter", condition = "tenant_id = :tenantId")
@Table(name = "sec_users", uniqueConstraints = {
@UniqueConstraint(name = "sec_uk_users_tenant_username", columnNames = {"tenant_id", "username"})
})
public class User extends BaseEntity {
@Id
@GeneratedValue
private UUID id;
@Column(nullable = false)
private String username;
@Column
private String password;
@Enumerated(EnumType.STRING)
@Column(name = "auth_source", nullable = false)
private AuthenticationSource authSource = AuthenticationSource.LOCAL;
@Column(name = "ldap_dn")
private String ldapDn;
@Column(nullable = false)
private boolean enabled = true;
@ManyToMany(fetch = FetchType.EAGER)
@JoinTable(
name = "sec_user_roles",
joinColumns = @JoinColumn(name = "user_id"),
inverseJoinColumns = @JoinColumn(name = "role_id")
)
private Set<Role> roles = new HashSet<>();
}

View File

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

View File

@ -0,0 +1,13 @@
package id.iptek.utms.auth.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import java.util.Set;
public record CreateRoleManagementRequest(
@NotBlank String code,
@NotBlank String name,
@NotEmpty Set<@NotBlank String> permissionCodes
) {
}

View File

@ -0,0 +1,26 @@
package id.iptek.utms.auth.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import java.util.Set;
public record CreateUserManagementRequest(
@NotBlank String username,
String password,
String ldapDn,
Boolean enabled,
@NotEmpty Set<@NotBlank String> roleCodes
) {
public boolean isEnabled() {
return enabled == null || enabled;
}
public String normalizedLdapDn() {
return ldapDn == null ? null : ldapDn.trim();
}
public boolean hasPassword() {
return password != null && !password.isBlank();
}
}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,12 @@
package id.iptek.utms.auth.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import java.util.Set;
public record UpdateRolePermissionsRequest(
@NotBlank String code,
@NotEmpty Set<@NotBlank String> permissionCodes
) {
}

View File

@ -0,0 +1,12 @@
package id.iptek.utms.auth.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import java.util.Set;
public record UpdateUserRolesRequest(
@NotBlank String username,
@NotEmpty Set<@NotBlank String> roleCodes
) {
}

View File

@ -0,0 +1,16 @@
package id.iptek.utms.auth.repository;
import id.iptek.utms.auth.domain.Permission;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
public interface PermissionRepository extends JpaRepository<Permission, UUID> {
Optional<Permission> findByTenantIdAndCode(String tenantId, String code);
List<Permission> findByTenantIdAndCodeIn(String tenantId, Collection<String> codes);
}

View File

@ -0,0 +1,13 @@
package id.iptek.utms.auth.repository;
import id.iptek.utms.auth.domain.RefreshToken;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
import java.util.UUID;
public interface RefreshTokenRepository extends JpaRepository<RefreshToken, UUID> {
Optional<RefreshToken> findByTokenAndTenantId(String token, String tenantId);
void deleteByUser_IdAndTenantId(UUID userId, String tenantId);
}

View File

@ -0,0 +1,16 @@
package id.iptek.utms.auth.repository;
import id.iptek.utms.auth.domain.Role;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
public interface RoleRepository extends JpaRepository<Role, UUID> {
Optional<Role> findByTenantIdAndCode(String tenantId, String code);
List<Role> findByTenantIdAndCodeIn(String tenantId, Collection<String> codes);
}

View File

@ -0,0 +1,14 @@
package id.iptek.utms.auth.repository;
import id.iptek.utms.auth.domain.User;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
import java.util.UUID;
public interface UserRepository extends JpaRepository<User, UUID> {
Optional<User> findByTenantIdAndUsername(String tenantId, String username);
boolean existsByTenantIdAndUsername(String tenantId, String username);
}

View File

@ -0,0 +1,87 @@
package id.iptek.utms.auth.security;
import id.iptek.utms.auth.service.TokenBlacklistService;
import id.iptek.utms.auth.service.SingleLoginSessionService;
import id.iptek.utms.tenant.TenantContext;
import io.jsonwebtoken.Claims;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.HttpHeaders;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtService jwtService;
private final UserDetailsService userDetailsService;
private final TokenBlacklistService tokenBlacklistService;
private final SingleLoginSessionService singleLoginSessionService;
public JwtAuthenticationFilter(JwtService jwtService,
UserDetailsService userDetailsService,
TokenBlacklistService tokenBlacklistService,
SingleLoginSessionService singleLoginSessionService) {
this.jwtService = jwtService;
this.userDetailsService = userDetailsService;
this.tokenBlacklistService = tokenBlacklistService;
this.singleLoginSessionService = singleLoginSessionService;
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION);
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
String token = authHeader.substring(7);
if (tokenBlacklistService.isBlacklisted(token)) {
filterChain.doFilter(request, response);
return;
}
try {
Claims claims = jwtService.parseClaims(token);
String username = claims.getSubject();
String tenant = claims.get("tenant", String.class);
String sessionId = claims.get("sid", String.class);
if (TenantContext.getTenantId() == null && tenant != null) {
TenantContext.setTenantId(tenant);
}
if (singleLoginSessionService.isEnabled() && !singleLoginSessionService.isSessionActive(tenant, username, sessionId)) {
filterChain.doFilter(request, response);
return;
}
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
if (jwtService.isTokenValid(token)) {
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authToken);
}
}
} catch (Exception ignored) {
SecurityContextHolder.clearContext();
}
filterChain.doFilter(request, response);
}
}

View File

@ -0,0 +1,91 @@
package id.iptek.utms.auth.security;
import id.iptek.utms.auth.config.JwtProperties;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Date;
import java.util.UUID;
@Component
public class JwtService {
private final JwtProperties jwtProperties;
public JwtService(JwtProperties jwtProperties) {
this.jwtProperties = jwtProperties;
}
public String generateAccessToken(UserPrincipal principal) {
return generateAccessToken(principal, null);
}
public String generateAccessToken(UserPrincipal principal, String sessionId) {
Instant now = Instant.now();
return buildToken(principal, now.plus(jwtProperties.accessTokenMinutes(), ChronoUnit.MINUTES), sessionId);
}
public String generateRefreshToken(UserPrincipal principal) {
return generateRefreshToken(principal, null);
}
public String generateRefreshToken(UserPrincipal principal, String sessionId) {
Instant now = Instant.now();
return buildToken(principal, now.plus(jwtProperties.refreshTokenDays(), ChronoUnit.DAYS), sessionId);
}
private String buildToken(UserPrincipal principal, Instant expiresAt, String sessionId) {
return Jwts.builder()
.subject(principal.getUsername())
.claim("uid", principal.getId().toString())
.claim("tenant", principal.getTenantId())
.claim("sid", sessionId)
.issuedAt(Date.from(Instant.now()))
.expiration(Date.from(expiresAt))
.signWith(secretKey())
.compact();
}
public Claims parseClaims(String token) {
return Jwts.parser()
.verifyWith(secretKey())
.build()
.parseSignedClaims(token)
.getPayload();
}
public String extractUsername(String token) {
return parseClaims(token).getSubject();
}
public String extractTenant(String token) {
return parseClaims(token).get("tenant", String.class);
}
public UUID extractUserId(String token) {
return UUID.fromString(parseClaims(token).get("uid", String.class));
}
public String extractSessionId(String token) {
return parseClaims(token).get("sid", String.class);
}
public boolean isTokenValid(String token) {
return parseClaims(token).getExpiration().after(new Date());
}
public long getAccessExpiresInSeconds() {
return jwtProperties.accessTokenMinutes() * 60;
}
private SecretKey secretKey() {
return Keys.hmacShaKeyFor(jwtProperties.secret().getBytes(StandardCharsets.UTF_8));
}
}

View File

@ -0,0 +1,27 @@
package id.iptek.utms.auth.security;
import id.iptek.utms.auth.repository.UserRepository;
import id.iptek.utms.tenant.TenantContext;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
@Service
public class TenantAwareUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
public TenantAwareUserDetailsService(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
String tenantId = TenantContext.getRequiredTenantId();
return userRepository.findByTenantIdAndUsername(tenantId, username)
.map(UserPrincipal::new)
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
}
}

View File

@ -0,0 +1,87 @@
package id.iptek.utms.auth.security;
import id.iptek.utms.auth.domain.Permission;
import id.iptek.utms.auth.domain.Role;
import id.iptek.utms.auth.domain.User;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.Collection;
import java.util.HashSet;
import java.util.Set;
import java.util.UUID;
public class UserPrincipal implements UserDetails {
private final UUID id;
private final String tenantId;
private final String username;
private final String password;
private final boolean enabled;
private final Set<GrantedAuthority> authorities;
public UserPrincipal(User user) {
this.id = user.getId();
this.tenantId = user.getTenantId();
this.username = user.getUsername();
this.password = user.getPassword();
this.enabled = user.isEnabled();
this.authorities = mapAuthorities(user.getRoles());
}
private Set<GrantedAuthority> mapAuthorities(Set<Role> roles) {
Set<GrantedAuthority> mapped = new HashSet<>();
for (Role role : roles) {
mapped.add(new SimpleGrantedAuthority("ROLE_" + role.getCode()));
for (Permission permission : role.getPermissions()) {
mapped.add(new SimpleGrantedAuthority(permission.getCode()));
}
}
return mapped;
}
public UUID getId() {
return id;
}
public String getTenantId() {
return tenantId;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return authorities;
}
@Override
public String getPassword() {
return password;
}
@Override
public String getUsername() {
return username;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return enabled;
}
}

View File

@ -0,0 +1,153 @@
package id.iptek.utms.auth.service;
import id.iptek.utms.auth.domain.RefreshToken;
import id.iptek.utms.auth.domain.User;
import id.iptek.utms.auth.dto.AuthTokenResponse;
import id.iptek.utms.auth.dto.LoginRequest;
import id.iptek.utms.auth.dto.RefreshRequest;
import id.iptek.utms.auth.repository.RefreshTokenRepository;
import id.iptek.utms.auth.repository.UserRepository;
import id.iptek.utms.auth.config.LdapProperties;
import id.iptek.utms.auth.security.JwtService;
import id.iptek.utms.auth.security.UserPrincipal;
import id.iptek.utms.core.i18n.MessageResolver;
import id.iptek.utms.core.exception.AppException;
import id.iptek.utms.tenant.TenantContext;
import id.iptek.utms.tenant.TenantService;
import io.jsonwebtoken.Claims;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Duration;
import java.time.Instant;
import java.util.UUID;
@Service
public class AuthService {
private final AuthenticationManager authenticationManager;
private final JwtService jwtService;
private final RefreshTokenRepository refreshTokenRepository;
private final UserRepository userRepository;
private final TokenBlacklistService tokenBlacklistService;
private final TenantService tenantService;
private final LoginThrottleService loginThrottleService;
private final SingleLoginSessionService singleLoginSessionService;
private final MessageResolver messageResolver;
private final LdapProperties ldapProperties;
public AuthService(AuthenticationManager authenticationManager,
JwtService jwtService,
RefreshTokenRepository refreshTokenRepository,
UserRepository userRepository,
TokenBlacklistService tokenBlacklistService,
TenantService tenantService,
LoginThrottleService loginThrottleService,
SingleLoginSessionService singleLoginSessionService,
MessageResolver messageResolver,
LdapProperties ldapProperties) {
this.authenticationManager = authenticationManager;
this.jwtService = jwtService;
this.refreshTokenRepository = refreshTokenRepository;
this.userRepository = userRepository;
this.tokenBlacklistService = tokenBlacklistService;
this.tenantService = tenantService;
this.loginThrottleService = loginThrottleService;
this.singleLoginSessionService = singleLoginSessionService;
this.messageResolver = messageResolver;
this.ldapProperties = ldapProperties;
}
@Transactional
public AuthTokenResponse login(LoginRequest request) {
String tenantId = TenantContext.getRequiredTenantId();
tenantService.getActiveTenant(tenantId);
loginThrottleService.ensureAllowed(tenantId, request.username());
try {
Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(request.username(), request.password())
);
loginThrottleService.recordSuccess(tenantId, request.username());
User user = resolveAuthenticatedUser(tenantId, authentication.getName());
UserPrincipal principal = new UserPrincipal(user);
String sessionId = UUID.randomUUID().toString();
String accessToken = jwtService.generateAccessToken(principal, sessionId);
String refreshToken = jwtService.generateRefreshToken(principal, sessionId);
refreshTokenRepository.deleteByUser_IdAndTenantId(user.getId(), tenantId);
RefreshToken entity = new RefreshToken();
entity.setUser(user);
entity.setToken(refreshToken);
Instant refreshExpiresAt = jwtService.parseClaims(refreshToken).getExpiration().toInstant();
entity.setExpiresAt(refreshExpiresAt);
entity.setRevoked(false);
entity.setTenantId(tenantId);
refreshTokenRepository.save(entity);
singleLoginSessionService.registerSession(tenantId, user.getUsername(), sessionId, refreshExpiresAt);
return new AuthTokenResponse("Bearer", accessToken, refreshToken, jwtService.getAccessExpiresInSeconds());
} catch (AppException ex) {
throw ex;
} catch (AuthenticationException ex) {
loginThrottleService.recordFailure(tenantId, request.username());
throw new AppException(messageResolver.get("auth.invalid.credentials"));
}
}
private User resolveAuthenticatedUser(String tenantId, String username) {
return userRepository.findByTenantIdAndUsername(tenantId, username)
.orElseThrow(() -> new AppException(ldapProperties.enabled()
? messageResolver.get("auth.user.notfound.for.ldap")
: messageResolver.get("auth.user.notfound")));
}
@Transactional
public AuthTokenResponse refresh(RefreshRequest request) {
String tenantId = TenantContext.getRequiredTenantId();
RefreshToken refreshToken = refreshTokenRepository.findByTokenAndTenantId(request.refreshToken(), tenantId)
.orElseThrow(() -> new AppException(messageResolver.get("auth.refresh.notfound")));
if (refreshToken.isRevoked() || refreshToken.getExpiresAt().isBefore(Instant.now())) {
throw new AppException(messageResolver.get("auth.refresh.invalid"));
}
Claims claims = jwtService.parseClaims(request.refreshToken());
String username = claims.getSubject();
User user = userRepository.findByTenantIdAndUsername(tenantId, username)
.orElseThrow(() -> new AppException(messageResolver.get("auth.user.notfound")));
String tokenSessionId = jwtService.extractSessionId(request.refreshToken());
if (singleLoginSessionService.isEnabled() && !singleLoginSessionService.isSessionActive(tenantId, username, tokenSessionId)) {
throw new AppException(messageResolver.get("auth.single.login.invalid_session"));
}
UserPrincipal principal = new UserPrincipal(user);
String accessToken = jwtService.generateAccessToken(principal, tokenSessionId);
return new AuthTokenResponse("Bearer", accessToken, request.refreshToken(), jwtService.getAccessExpiresInSeconds());
}
@Transactional
public void logout(String accessToken) {
if (accessToken == null || accessToken.isBlank()) {
return;
}
Claims claims = jwtService.parseClaims(accessToken);
Instant expiry = claims.getExpiration().toInstant();
String tenantId = claims.get("tenant", String.class);
String username = claims.getSubject();
String sessionId = jwtService.extractSessionId(accessToken);
singleLoginSessionService.clearSession(tenantId, username, sessionId);
Duration ttl = Duration.between(Instant.now(), expiry);
if (!ttl.isNegative() && !ttl.isZero()) {
tokenBlacklistService.blacklist(accessToken, ttl);
}
}
}

View File

@ -0,0 +1,84 @@
package id.iptek.utms.auth.service;
import id.iptek.utms.core.exception.AppException;
import id.iptek.utms.core.i18n.MessageResolver;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Locale;
import java.util.Optional;
@Service
public class LoginThrottleService {
private final StringRedisTemplate redisTemplate;
private final MessageResolver messageResolver;
private final int maxFailedAttempts;
private final int failedAttemptWindowSeconds;
private final int lockoutDurationSeconds;
private static final String FAIL_PREFIX = "utms:auth:login:fail:";
private static final String LOCK_PREFIX = "utms:auth:login:lock:";
public LoginThrottleService(StringRedisTemplate redisTemplate,
MessageResolver messageResolver,
@Value("${app.security.login.max-failed-attempts:5}") int maxFailedAttempts,
@Value("${app.security.login.failed-attempt-window-seconds:900}") int failedAttemptWindowSeconds,
@Value("${app.security.login.lockout-duration-seconds:300}") int lockoutDurationSeconds) {
this.redisTemplate = redisTemplate;
this.messageResolver = messageResolver;
this.maxFailedAttempts = maxFailedAttempts;
this.failedAttemptWindowSeconds = failedAttemptWindowSeconds;
this.lockoutDurationSeconds = lockoutDurationSeconds;
}
public void ensureAllowed(String tenantId, String username) {
String lockKey = lockKey(tenantId, username);
Long ttl = redisTemplate.getExpire(lockKey);
if (ttl == null || ttl < 1) {
redisTemplate.delete(lockKey);
return;
}
throw new AppException(messageResolver.get("auth.login.locked", Math.max(ttl, 0L)));
}
public void recordFailure(String tenantId, String username) {
String failKey = failKey(tenantId, username);
String lockKey = lockKey(tenantId, username);
Long attempts = Optional.ofNullable(redisTemplate.opsForValue().increment(failKey)).orElse(1L);
if (attempts == 1) {
redisTemplate.expire(failKey, Duration.ofSeconds(failedAttemptWindowSeconds));
}
if (attempts >= maxFailedAttempts) {
redisTemplate.opsForValue().set(lockKey, "locked");
redisTemplate.expire(lockKey, Duration.ofSeconds(lockoutDurationSeconds));
redisTemplate.delete(failKey);
long ttl = redisTemplate.getExpire(lockKey);
throw new AppException(messageResolver.get("auth.login.locked", Math.max(ttl, (long) lockoutDurationSeconds)));
}
}
public void recordSuccess(String tenantId, String username) {
redisTemplate.delete(failKey(tenantId, username));
redisTemplate.delete(lockKey(tenantId, username));
}
private String failKey(String tenantId, String username) {
return FAIL_PREFIX + normalize(tenantId) + ":" + normalize(username);
}
private String lockKey(String tenantId, String username) {
return LOCK_PREFIX + normalize(tenantId) + ":" + normalize(username);
}
private String normalize(String value) {
return URLEncoder.encode(value.toLowerCase(Locale.ROOT), StandardCharsets.UTF_8);
}
}

View File

@ -0,0 +1,80 @@
package id.iptek.utms.auth.service;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.time.Instant;
@Service
public class SingleLoginSessionService {
private static final String SESSION_KEY_PREFIX = "utms:auth:single-login:";
private final StringRedisTemplate redisTemplate;
private final boolean enabled;
public SingleLoginSessionService(StringRedisTemplate redisTemplate,
@Value("${app.security.single-login.enabled:false}") boolean enabled) {
this.redisTemplate = redisTemplate;
this.enabled = enabled;
}
public void registerSession(String tenantId, String username, String sessionId, Instant sessionExpiresAt) {
if (!enabled) {
return;
}
String key = sessionKey(tenantId, username);
Duration ttl = ttlUntil(sessionExpiresAt);
redisTemplate.opsForValue().set(key, sessionId, ttl);
}
public boolean isSessionActive(String tenantId, String username, String sessionId) {
if (!enabled) {
return true;
}
if (tenantId == null || username == null || sessionId == null || tenantId.isBlank() || username.isBlank()) {
return false;
}
String activeSession = redisTemplate.opsForValue().get(sessionKey(tenantId, username));
return sessionId.equals(activeSession);
}
public void clearSession(String tenantId, String username, String sessionId) {
if (!enabled || tenantId == null || username == null || tenantId.isBlank() || username.isBlank()) {
return;
}
String key = sessionKey(tenantId, username);
String activeSession = redisTemplate.opsForValue().get(key);
if (sessionId == null || sessionId.equals(activeSession)) {
redisTemplate.delete(key);
}
}
public void clearAllSessions(String tenantId, String username) {
if (!enabled || tenantId == null || username == null || tenantId.isBlank() || username.isBlank()) {
return;
}
redisTemplate.delete(sessionKey(tenantId, username));
}
public boolean isEnabled() {
return enabled;
}
private Duration ttlUntil(Instant expiresAt) {
long seconds = Duration.between(Instant.now(), expiresAt).getSeconds();
if (seconds <= 0) {
return Duration.ofSeconds(1);
}
return Duration.ofSeconds(seconds);
}
private String sessionKey(String tenantId, String username) {
return SESSION_KEY_PREFIX + tenantId + ":" + username;
}
}

View File

@ -0,0 +1,27 @@
package id.iptek.utms.auth.service;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.time.Duration;
@Service
public class TokenBlacklistService {
private static final String KEY_PREFIX = "auth:blacklist:";
private final StringRedisTemplate redisTemplate;
public TokenBlacklistService(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
public void blacklist(String token, Duration ttl) {
redisTemplate.opsForValue().set(KEY_PREFIX + token, "1", ttl);
}
public boolean isBlacklisted(String token) {
return Boolean.TRUE.equals(redisTemplate.hasKey(KEY_PREFIX + token));
}
}

View File

@ -0,0 +1,413 @@
package id.iptek.utms.auth.service;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import id.iptek.utms.auth.domain.Permission;
import id.iptek.utms.auth.domain.Role;
import id.iptek.utms.auth.domain.User;
import id.iptek.utms.auth.domain.AuthenticationSource;
import id.iptek.utms.auth.config.LdapProperties;
import id.iptek.utms.auth.dto.CreateRoleManagementRequest;
import id.iptek.utms.auth.dto.CreateUserManagementRequest;
import id.iptek.utms.auth.dto.UpdateRolePermissionsRequest;
import id.iptek.utms.auth.dto.UpdateUserRolesRequest;
import id.iptek.utms.auth.repository.PermissionRepository;
import id.iptek.utms.auth.repository.RoleRepository;
import id.iptek.utms.auth.repository.UserRepository;
import id.iptek.utms.core.audit.service.AuditTrailService;
import id.iptek.utms.core.exception.AppException;
import id.iptek.utms.messaging.ApprovalCompletedEvent;
import id.iptek.utms.tenant.TenantContext;
import id.iptek.utms.workflow.domain.ApprovalRequest;
import id.iptek.utms.workflow.dto.ApprovalResponse;
import id.iptek.utms.workflow.repository.ApprovalRequestRepository;
import id.iptek.utms.workflow.service.ApprovalWorkflowService;
import id.iptek.utms.workflow.domain.ApprovalStatus;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.CollectionUtils;
import jakarta.servlet.http.HttpServletRequest;
import java.util.*;
import java.util.stream.Collectors;
@Service
public class UserRoleManagementService {
public static final String CHECKER_ROLE_MANAGER = "USER_ROLE_ADMIN";
private final UserRepository userRepository;
private final RoleRepository roleRepository;
private final PermissionRepository permissionRepository;
private final ApprovalRequestRepository approvalRequestRepository;
private final ApprovalWorkflowService approvalWorkflowService;
private final AuditTrailService auditTrailService;
private final PasswordEncoder passwordEncoder;
private final ObjectMapper objectMapper;
private final LdapProperties ldapProperties;
public UserRoleManagementService(UserRepository userRepository,
RoleRepository roleRepository,
PermissionRepository permissionRepository,
ApprovalRequestRepository approvalRequestRepository,
ApprovalWorkflowService approvalWorkflowService,
AuditTrailService auditTrailService,
PasswordEncoder passwordEncoder,
ObjectMapper objectMapper,
LdapProperties ldapProperties) {
this.userRepository = userRepository;
this.roleRepository = roleRepository;
this.permissionRepository = permissionRepository;
this.approvalRequestRepository = approvalRequestRepository;
this.approvalWorkflowService = approvalWorkflowService;
this.auditTrailService = auditTrailService;
this.passwordEncoder = passwordEncoder;
this.objectMapper = objectMapper;
this.ldapProperties = ldapProperties;
}
@Transactional
public ApprovalResponse submitCreateUserRequest(CreateUserManagementRequest request, HttpServletRequest servletRequest) {
String tenantId = TenantContext.getRequiredTenantId();
assertUserNotExists(tenantId, request.username());
Set<Role> roles = resolveRoles(tenantId, request.roleCodes());
if (!ldapProperties.enabled() && !request.hasPassword()) {
throw new AppException("Password is required when LDAP is disabled");
}
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("operation", "CREATE_USER");
payload.put("username", request.username());
if (!ldapProperties.enabled()) {
payload.put("passwordHash", passwordEncoder.encode(request.password()));
}
payload.put("authSource", ldapProperties.enabled() ? AuthenticationSource.LDAP.name() : AuthenticationSource.LOCAL.name());
payload.put("ldapDn", request.normalizedLdapDn());
payload.put("enabled", request.isEnabled());
payload.put("roleCodes", request.roleCodes());
return approvalWorkflowService.createRequest(
"USER_MANAGEMENT",
request.username(),
toJson(payload),
1,
CHECKER_ROLE_MANAGER,
servletRequest
);
}
@Transactional
public ApprovalResponse submitUpdateUserRolesRequest(UpdateUserRolesRequest request, HttpServletRequest servletRequest) {
String tenantId = TenantContext.getRequiredTenantId();
User target = userRepository.findByTenantIdAndUsername(tenantId, request.username())
.orElseThrow(() -> new AppException("User not found"));
Set<Role> roles = resolveRoles(tenantId, request.roleCodes());
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("operation", "UPDATE_USER_ROLES");
payload.put("userId", target.getId().toString());
payload.put("username", request.username());
payload.put("roleCodes", request.roleCodes());
return approvalWorkflowService.createRequest(
"USER_MANAGEMENT",
request.username(),
toJson(payload),
1,
CHECKER_ROLE_MANAGER,
servletRequest
);
}
@Transactional
public ApprovalResponse submitCreateRoleRequest(CreateRoleManagementRequest request, HttpServletRequest servletRequest) {
String tenantId = TenantContext.getRequiredTenantId();
if (roleRepository.findByTenantIdAndCode(tenantId, request.code()).isPresent()) {
throw new AppException("Role already exists");
}
resolvePermissions(tenantId, request.permissionCodes());
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("operation", "CREATE_ROLE");
payload.put("code", request.code());
payload.put("name", request.name());
payload.put("permissionCodes", request.permissionCodes());
return approvalWorkflowService.createRequest(
"ROLE_MANAGEMENT",
request.code(),
toJson(payload),
1,
CHECKER_ROLE_MANAGER,
servletRequest
);
}
@Transactional
public ApprovalResponse submitUpdateRolePermissionsRequest(UpdateRolePermissionsRequest request, HttpServletRequest servletRequest) {
String tenantId = TenantContext.getRequiredTenantId();
roleRepository.findByTenantIdAndCode(tenantId, request.code())
.orElseThrow(() -> new AppException("Role not found"));
resolvePermissions(tenantId, request.permissionCodes());
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("operation", "UPDATE_ROLE_PERMISSIONS");
payload.put("code", request.code());
payload.put("permissionCodes", request.permissionCodes());
return approvalWorkflowService.createRequest(
"ROLE_MANAGEMENT",
request.code(),
toJson(payload),
1,
CHECKER_ROLE_MANAGER,
servletRequest
);
}
@Transactional
public void applyApprovedRequest(ApprovalCompletedEvent event) {
ApprovalRequest approvalRequest = approvalRequestRepository
.findByIdAndTenantId(event.requestId(), event.tenantId())
.orElse(null);
if (approvalRequest == null) {
return;
}
if (approvalRequest.getStatus() != ApprovalStatus.APPROVED) {
return;
}
String tenantId = event.tenantId();
try {
TenantContext.setTenantId(tenantId);
Map<String, Object> payload = objectMapper.readValue(approvalRequest.getPayload(), new TypeReference<>() {});
String operation = payload.get("operation") != null ? payload.get("operation").toString() : "";
switch (operation) {
case "CREATE_USER" -> applyCreateUser(approvalRequest, payload, tenantId, event);
case "UPDATE_USER_ROLES" -> applyUpdateUserRoles(approvalRequest, payload, tenantId, event);
case "CREATE_ROLE" -> applyCreateRole(approvalRequest, payload, tenantId, event);
case "UPDATE_ROLE_PERMISSIONS" -> applyUpdateRolePermissions(approvalRequest, payload, tenantId, event);
default -> {
auditTrailService.record(
"USER_ROLE_MANAGEMENT_UNKNOWN",
"WORKFLOW",
approvalRequest.getResourceType(),
approvalRequest.getId().toString(),
AuditTrailService.FAILURE,
"Unknown workflow operation",
null,
null,
null,
null
);
throw new AppException("Unknown user/role management operation");
}
}
} catch (Exception ex) {
throw new AppException("Failed to apply approved management request: " + ex.getMessage());
} finally {
TenantContext.clear();
}
}
@Transactional
protected void applyCreateUser(ApprovalRequest approvalRequest, Map<String, Object> payload, String tenantId, ApprovalCompletedEvent event) {
String username = (String) payload.get("username");
String authSourceName = (String) payload.get("authSource");
AuthenticationSource authSource = parseAuthSource(authSourceName, "LOCAL");
boolean enabled = asBoolean(payload.get("enabled"), true);
List<String> roleCodes = castToStringList(payload.get("roleCodes"));
Set<Role> roles = resolveRoles(tenantId, roleCodes);
String ldapDn = (String) payload.get("ldapDn");
User user = userRepository.findByTenantIdAndUsername(tenantId, username)
.orElseGet(() -> {
User created = new User();
created.setTenantId(tenantId);
created.setUsername(username);
return created;
});
Map<String, Object> before = snapshotUser(user);
if (authSource == AuthenticationSource.LOCAL) {
String passwordHash = (String) payload.get("passwordHash");
if (passwordHash == null) {
throw new AppException("Local user creation requires password hash");
}
user.setPassword(passwordHash);
} else {
user.setPassword(null);
user.setLdapDn(ldapDn);
}
user.setAuthSource(authSource);
user.setEnabled(enabled);
user.setRoles(new LinkedHashSet<>(roles));
User after = userRepository.save(user);
recordManagementTrail("USER_CREATE_APPLY", approvalRequest, event.approvedBy(), before, snapshotUser(after), tenantId);
}
@Transactional
protected void applyUpdateUserRoles(ApprovalRequest approvalRequest, Map<String, Object> payload, String tenantId, ApprovalCompletedEvent event) {
String username = (String) payload.get("username");
User user = userRepository.findByTenantIdAndUsername(tenantId, username)
.orElseThrow(() -> new AppException("User not found"));
List<String> roleCodes = castToStringList(payload.get("roleCodes"));
Set<Role> roles = resolveRoles(tenantId, roleCodes);
Map<String, Object> before = snapshotUser(user);
user.setRoles(new LinkedHashSet<>(roles));
User after = userRepository.save(user);
recordManagementTrail("USER_UPDATE_ROLES_APPLY", approvalRequest, event.approvedBy(), before, snapshotUser(after), tenantId);
}
@Transactional
protected void applyCreateRole(ApprovalRequest approvalRequest, Map<String, Object> payload, String tenantId, ApprovalCompletedEvent event) {
String code = (String) payload.get("code");
String name = (String) payload.get("name");
List<String> permissionCodes = castToStringList(payload.get("permissionCodes"));
Set<Permission> permissions = resolvePermissions(tenantId, permissionCodes);
Role role = roleRepository.findByTenantIdAndCode(tenantId, code)
.orElseGet(() -> {
Role created = new Role();
created.setTenantId(tenantId);
created.setCode(code);
created.setName(name);
created.setPermissions(permissions);
return roleRepository.save(created);
});
Map<String, Object> before = snapshotRole(role);
role.setName(name);
role.setPermissions(permissions);
Role after = roleRepository.save(role);
recordManagementTrail("ROLE_CREATE_APPLY", approvalRequest, event.approvedBy(), before, snapshotRole(after), tenantId);
}
@Transactional
protected void applyUpdateRolePermissions(ApprovalRequest approvalRequest, Map<String, Object> payload, String tenantId, ApprovalCompletedEvent event) {
String code = (String) payload.get("code");
List<String> permissionCodes = castToStringList(payload.get("permissionCodes"));
Role role = roleRepository.findByTenantIdAndCode(tenantId, code)
.orElseThrow(() -> new AppException("Role not found"));
Set<Permission> permissions = resolvePermissions(tenantId, permissionCodes);
Map<String, Object> before = snapshotRole(role);
role.setPermissions(permissions);
Role after = roleRepository.save(role);
recordManagementTrail("ROLE_UPDATE_PERMISSIONS_APPLY", approvalRequest, event.approvedBy(), before, snapshotRole(after), tenantId);
}
private void recordManagementTrail(String action,
ApprovalRequest approvalRequest,
String actor,
Map<String, Object> before,
Map<String, Object> after,
String tenantId) {
TenantContext.setTenantId(tenantId);
auditTrailService.record(
action,
"AUTH",
approvalRequest.getResourceType(),
approvalRequest.getResourceId(),
AuditTrailService.SUCCESS,
"Management request applied",
auditTrailService.toJson(before),
auditTrailService.toJson(after),
null,
null
);
}
private void assertUserNotExists(String tenantId, String username) {
if (userRepository.existsByTenantIdAndUsername(tenantId, username)) {
throw new AppException("User already exists");
}
}
private AuthenticationSource parseAuthSource(String source, String fallback) {
if (source == null || source.isBlank()) {
return AuthenticationSource.valueOf(fallback);
}
try {
return AuthenticationSource.valueOf(source);
} catch (IllegalArgumentException ex) {
return AuthenticationSource.valueOf(fallback);
}
}
private Set<Role> resolveRoles(String tenantId, Collection<String> roleCodes) {
if (CollectionUtils.isEmpty(roleCodes)) {
return Set.of();
}
Set<Role> roles = roleRepository.findByTenantIdAndCodeIn(tenantId, roleCodes)
.stream().collect(Collectors.toCollection(LinkedHashSet::new));
if (roles.size() != roleCodes.size()) {
throw new AppException("Some role codes are invalid");
}
return roles;
}
private Set<Permission> resolvePermissions(String tenantId, Collection<String> permissionCodes) {
if (CollectionUtils.isEmpty(permissionCodes)) {
return Set.of();
}
List<Permission> permissions = permissionRepository.findByTenantIdAndCodeIn(tenantId, permissionCodes);
if (permissions.size() != permissionCodes.size()) {
throw new AppException("Some permission codes are invalid");
}
return new LinkedHashSet<>(permissions);
}
private List<String> castToStringList(Object raw) {
if (!(raw instanceof Collection<?> values) || CollectionUtils.isEmpty(values)) {
return List.of();
}
return values.stream()
.map(String::valueOf)
.toList();
}
private Map<String, Object> snapshotUser(User user) {
Map<String, Object> snapshot = new LinkedHashMap<>();
snapshot.put("id", user.getId() != null ? user.getId().toString() : null);
snapshot.put("tenantId", user.getTenantId());
snapshot.put("username", user.getUsername());
snapshot.put("authSource", user.getAuthSource());
snapshot.put("ldapDn", user.getLdapDn());
snapshot.put("enabled", user.isEnabled());
snapshot.put("roles", user.getRoles().stream()
.map(Role::getCode)
.sorted()
.toList());
return snapshot;
}
private Map<String, Object> snapshotRole(Role role) {
Map<String, Object> snapshot = new LinkedHashMap<>();
snapshot.put("id", role.getId() != null ? role.getId().toString() : null);
snapshot.put("tenantId", role.getTenantId());
snapshot.put("code", role.getCode());
snapshot.put("name", role.getName());
snapshot.put("permissions", role.getPermissions().stream()
.map(Permission::getCode)
.sorted()
.toList());
return snapshot;
}
private boolean asBoolean(Object value, boolean defaultValue) {
if (value == null) {
return defaultValue;
}
if (value instanceof Boolean bool) {
return bool;
}
return Boolean.parseBoolean(value.toString());
}
private String toJson(Map<String, Object> payload) {
return auditTrailService.toJson(payload);
}
}

View File

@ -0,0 +1,37 @@
package id.iptek.utms.auth.service;
import id.iptek.utms.auth.domain.Role;
import id.iptek.utms.auth.domain.User;
import id.iptek.utms.auth.dto.CurrentUserResponse;
import id.iptek.utms.auth.repository.UserRepository;
import id.iptek.utms.core.exception.AppException;
import id.iptek.utms.tenant.TenantContext;
import org.springframework.stereotype.Service;
import java.util.Set;
import java.util.stream.Collectors;
@Service
public class UserService {
private final UserRepository userRepository;
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
public CurrentUserResponse me(String username) {
String tenantId = TenantContext.getRequiredTenantId();
User user = userRepository.findByTenantIdAndUsername(tenantId, username)
.orElseThrow(() -> new AppException("User not found"));
Set<String> roleCodes = user.getRoles().stream().map(Role::getCode).collect(Collectors.toSet());
Set<String> permissions = user.getRoles().stream()
.flatMap(role -> role.getPermissions().stream())
.map(permission -> permission.getCode())
.collect(Collectors.toSet());
return new CurrentUserResponse(tenantId, user.getUsername(), roleCodes, permissions);
}
}

View File

@ -0,0 +1,69 @@
package id.iptek.utms.core.audit.domain;
import id.iptek.utms.core.domain.BaseEntity;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import java.util.UUID;
@Getter
@Setter
@Entity
@Table(
name = "sys_audit_trails",
indexes = {
@Index(name = "sys_idx_audit_tenant_created", columnList = "tenant_id,created_at"),
@Index(name = "sys_idx_audit_correlation", columnList = "correlation_id"),
@Index(name = "sys_idx_audit_actor", columnList = "actor"),
@Index(name = "sys_idx_audit_action", columnList = "action")
}
)
public class AuditTrail extends BaseEntity {
@Id
@GeneratedValue
private UUID id;
@Column(name = "actor", nullable = false, length = 255)
private String actor;
@Column(name = "action", nullable = false, length = 100)
private String action;
@Column(name = "correlation_id", length = 100)
private String correlationId;
@Column(name = "domain", length = 100)
private String domain;
@Column(name = "resource_type", length = 100)
private String resourceType;
@Column(name = "resource_id", length = 255)
private String resourceId;
@Column(name = "outcome", nullable = false, length = 20)
private String outcome;
@Column(name = "http_method", length = 20)
private String httpMethod;
@Column(name = "request_path", length = 500)
private String requestPath;
@Column(name = "client_ip", length = 80)
private String clientIp;
@Column(name = "error_message", length = 1000)
private String errorMessage;
@Column(name = "details", columnDefinition = "text")
private String details;
@Column(name = "before_state", columnDefinition = "text")
private String beforeState;
@Column(name = "after_state", columnDefinition = "text")
private String afterState;
}

View File

@ -0,0 +1,23 @@
package id.iptek.utms.core.audit.dto;
import java.time.Instant;
import java.util.UUID;
public record AuditTrailResponse(
UUID id,
String tenantId,
String actor,
String correlationId,
String action,
String domain,
String resourceType,
String resourceId,
String outcome,
String httpMethod,
String requestPath,
String errorMessage,
String beforeState,
String afterState,
String details,
Instant createdAt
) {}

View File

@ -0,0 +1,12 @@
package id.iptek.utms.core.audit.repository;
import id.iptek.utms.core.audit.domain.AuditTrail;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.UUID;
public interface AuditTrailRepository extends JpaRepository<AuditTrail, UUID> {
Page<AuditTrail> findByTenantIdOrderByCreatedAtDesc(String tenantId, Pageable pageable);
}

View File

@ -0,0 +1,146 @@
package id.iptek.utms.core.audit.service;
import id.iptek.utms.core.audit.domain.AuditTrail;
import id.iptek.utms.core.audit.repository.AuditTrailRepository;
import id.iptek.utms.core.security.SecurityUtils;
import id.iptek.utms.tenant.TenantContext;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Map;
@Service
public class AuditTrailService {
public static final String SUCCESS = "SUCCESS";
public static final String FAILURE = "FAILURE";
public static final String CORRELATION_ID_ATTRIBUTE = "UTMS_AUDIT_CORRELATION_ID";
private final AuditTrailRepository auditTrailRepository;
private final ObjectMapper objectMapper;
public AuditTrailService(AuditTrailRepository auditTrailRepository, ObjectMapper objectMapper) {
this.auditTrailRepository = auditTrailRepository;
this.objectMapper = objectMapper;
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void record(String action,
String domain,
String resourceType,
String resourceId,
String outcome,
String details,
String errorMessage,
HttpServletRequest request) {
record(action, domain, resourceType, resourceId, outcome, details, null, null, null, errorMessage, request);
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void record(String action,
String domain,
String resourceType,
String resourceId,
String outcome,
String details,
String beforeState,
String afterState,
String errorMessage,
HttpServletRequest request) {
String correlationId = resolveOrCreateCorrelationId(request);
record(action, domain, resourceType, resourceId, outcome, details, beforeState, afterState, correlationId, errorMessage, request);
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void record(String action,
String domain,
String resourceType,
String resourceId,
String outcome,
String details,
String beforeState,
String afterState,
String correlationId,
String errorMessage,
HttpServletRequest request) {
String tenantId = TenantContext.getTenantId() != null ? TenantContext.getTenantId() : "system";
String actor = SecurityUtils.currentUsername();
if (actor == null || actor.isBlank()) {
actor = "anonymous";
}
AuditTrail trail = new AuditTrail();
trail.setTenantId(tenantId);
trail.setCorrelationId(correlationId);
trail.setActor(actor);
trail.setAction(action);
trail.setDomain(domain);
trail.setResourceType(resourceType);
trail.setResourceId(resourceId);
trail.setOutcome(outcome != null ? outcome : SUCCESS);
trail.setErrorMessage(errorMessage);
trail.setDetails(details);
trail.setBeforeState(beforeState);
trail.setAfterState(afterState);
if (request != null) {
trail.setHttpMethod(request.getMethod());
trail.setRequestPath(request.getRequestURI());
trail.setClientIp(request.getRemoteAddr());
}
auditTrailRepository.save(trail);
}
private String resolveOrCreateCorrelationId(HttpServletRequest request) {
if (request == null) {
return java.util.UUID.randomUUID().toString();
}
Object existing = request.getAttribute(CORRELATION_ID_ATTRIBUTE);
if (existing instanceof String existingValue && !existingValue.isBlank()) {
return existingValue;
}
String created = java.util.UUID.randomUUID().toString();
request.setAttribute(CORRELATION_ID_ATTRIBUTE, created);
return created;
}
public String toJson(Object value) {
if (value == null) {
return null;
}
try {
if (value instanceof String stringValue) {
return stringValue;
}
return objectMapper.writeValueAsString(value);
} catch (Exception ex) {
return objectMapper.createObjectNode()
.put("type", value.getClass().getName())
.put("value", String.valueOf(value))
.toString();
}
}
public String toChangeSummary(String event, Map<String, Object> before, Map<String, Object> after) {
return toJson(Map.of(
"event", event,
"before", before,
"after", after
));
}
public List<AuditTrail> listRecent(String tenantId, int limit) {
int normalized = Math.max(1, Math.min(limit, 500));
return auditTrailRepository.findByTenantIdOrderByCreatedAtDesc(
tenantId,
PageRequest.of(0, normalized, Sort.by(Sort.Direction.DESC, "createdAt"))
).getContent();
}
}

View File

@ -0,0 +1,10 @@
package id.iptek.utms.core.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.jms.annotation.EnableJms;
@EnableJms
@Configuration
public class ActiveMqConfig {
}

View File

@ -0,0 +1,72 @@
package id.iptek.utms.core.config;
import id.iptek.utms.core.security.SecurityUtils;
import id.iptek.utms.core.audit.service.AuditTrailService;
import id.iptek.utms.tenant.TenantContext;
import jakarta.servlet.http.HttpServletRequest;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
@Aspect
@Component
public class AuditLoggingAspect {
private static final Logger log = LoggerFactory.getLogger(AuditLoggingAspect.class);
private final AuditTrailService auditTrailService;
public AuditLoggingAspect(AuditTrailService auditTrailService) {
this.auditTrailService = auditTrailService;
}
@Around("execution(* id.iptek.utms..controller..*(..))")
public Object auditControllerCalls(ProceedingJoinPoint joinPoint) throws Throwable {
String user = SecurityUtils.currentUsername();
String tenant = TenantContext.getTenantId() != null ? TenantContext.getTenantId() : "system";
String method = joinPoint.getSignature().toShortString();
HttpServletRequest request = currentRequest();
log.info("AUDIT START method={} user={} tenant={}", method, user, tenant);
try {
Object result = joinPoint.proceed();
log.info("AUDIT SUCCESS method={} user={} tenant={}", method, user, tenant);
auditTrailService.record(
method,
"CONTROLLER",
null,
null,
AuditTrailService.SUCCESS,
"Controller invocation completed",
null,
request
);
return result;
} catch (Throwable t) {
log.warn("AUDIT FAIL method={} user={} tenant={} error={}", method, user, tenant, t.getMessage());
auditTrailService.record(
method,
"CONTROLLER",
null,
null,
AuditTrailService.FAILURE,
"Controller invocation failed",
t.getMessage(),
request
);
throw t;
}
}
private HttpServletRequest currentRequest() {
if (RequestContextHolder.getRequestAttributes() instanceof ServletRequestAttributes attributes) {
return attributes.getRequest();
}
return null;
}
}

View File

@ -0,0 +1,183 @@
package id.iptek.utms.core.config;
import id.iptek.utms.auth.domain.Permission;
import id.iptek.utms.auth.domain.Role;
import id.iptek.utms.auth.domain.User;
import id.iptek.utms.auth.repository.PermissionRepository;
import id.iptek.utms.auth.repository.RoleRepository;
import id.iptek.utms.auth.repository.UserRepository;
import id.iptek.utms.module.domain.SystemModule;
import id.iptek.utms.module.repository.SystemModuleRepository;
import id.iptek.utms.tenant.Tenant;
import id.iptek.utms.tenant.TenantContext;
import id.iptek.utms.tenant.TenantRepository;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Profile;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
@Component
@Profile("dev")
public class DataSeeder implements CommandLineRunner {
private final TenantRepository tenantRepository;
private final PermissionRepository permissionRepository;
private final RoleRepository roleRepository;
private final UserRepository userRepository;
private final SystemModuleRepository moduleRepository;
private final PasswordEncoder passwordEncoder;
private final boolean seedEnabled;
public DataSeeder(TenantRepository tenantRepository,
PermissionRepository permissionRepository,
RoleRepository roleRepository,
UserRepository userRepository,
SystemModuleRepository moduleRepository,
PasswordEncoder passwordEncoder,
@org.springframework.beans.factory.annotation.Value("${app.seed.enabled:false}") boolean seedEnabled) {
this.tenantRepository = tenantRepository;
this.permissionRepository = permissionRepository;
this.roleRepository = roleRepository;
this.userRepository = userRepository;
this.moduleRepository = moduleRepository;
this.passwordEncoder = passwordEncoder;
this.seedEnabled = seedEnabled;
}
@Override
@Transactional
public void run(String... args) {
if (!seedEnabled) {
return;
}
seedTenantData("acme", "Acme Corporation");
seedTenantData("test", "Test Tenant");
}
private void seedTenantData(String tenantId, String tenantName) {
Tenant tenant = tenantRepository.findByTenantIdAndActiveTrue(tenantId).orElseGet(() -> {
Tenant t = new Tenant();
t.setTenantId(tenantId);
t.setName(tenantName);
t.setActive(true);
return tenantRepository.save(t);
});
TenantContext.setTenantId(tenant.getTenantId());
try {
Permission userRead = getOrCreatePermission("USER_READ", "Read user profile");
Permission userManage = getOrCreatePermission("USER_MANAGE", "Manage users");
Permission roleManage = getOrCreatePermission("ROLE_MANAGE", "Manage roles");
Permission workflowCreate = getOrCreatePermission("WORKFLOW_CREATE", "Create workflow request");
Permission workflowApprove = getOrCreatePermission("WORKFLOW_APPROVE", "Approve workflow request");
// Legacy sample roles
Role maker = getOrCreateRole("MAKER", "Maker", Set.of(workflowCreate));
Role checker = getOrCreateRole("CHECKER", "Checker", Set.of(workflowApprove));
Role admin = getOrCreateRole("ADMIN", "Administrator", Set.of(userRead, workflowCreate, workflowApprove));
// Bootstrap manager role that can manage both user and role lifecycle via workflow
Role userRoleAdmin = getOrCreateRole(
"USER_ROLE_ADMIN",
"User & Role Administrator",
Set.of(userRead, userManage, roleManage, workflowCreate, workflowApprove)
);
getOrCreateUser("maker", "Passw0rd!", Set.of(maker));
getOrCreateUser("checker", "Passw0rd!", Set.of(checker));
getOrCreateUser("admin", "Passw0rd!", Set.of(admin));
getOrCreateUser("system.manager", "Passw0rd!", Set.of(userRoleAdmin));
getOrCreateUser("system.owner", "Passw0rd!", Set.of(admin, userRoleAdmin));
if ("acme".equals(tenantId)) {
getOrCreateUser("acme.owner", "Passw0rd!", Set.of(admin));
getOrCreateModule("NOTIFICATION", "Notification Module", true);
getOrCreateModule("REPORTING", "Reporting Module", false);
getOrCreateModule("AUDIT", "Audit Trail Module", true);
} else {
getOrCreateModule("NOTIFICATION", "Notification Module", true);
}
} finally {
TenantContext.clear();
}
}
private Permission getOrCreatePermission(String code, String name) {
return permissionRepository.findByTenantIdAndCode(TenantContext.getRequiredTenantId(), code)
.orElseGet(() -> {
Permission p = new Permission();
p.setCode(code);
p.setName(name);
p.setTenantId(TenantContext.getRequiredTenantId());
return permissionRepository.save(p);
});
}
private Role getOrCreateRole(String code, String name, Set<Permission> permissions) {
String tenantId = TenantContext.getRequiredTenantId();
Role role = roleRepository.findByTenantIdAndCode(tenantId, code)
.orElseGet(() -> {
Role roleEntity = new Role();
roleEntity.setCode(code);
roleEntity.setName(name);
roleEntity.setPermissions(new HashSet<>());
roleEntity.setTenantId(tenantId);
return roleEntity;
});
role.setName(name);
role.setTenantId(tenantId);
addMissingByCode(role.getPermissions(), permissions, Permission::getCode);
return roleRepository.save(role);
}
private void getOrCreateUser(String username, String rawPassword, Set<Role> roles) {
String tenantId = TenantContext.getRequiredTenantId();
User user = userRepository.findByTenantIdAndUsername(tenantId, username)
.orElseGet(() -> {
User created = new User();
created.setUsername(username);
created.setPassword(passwordEncoder.encode(rawPassword));
created.setEnabled(true);
created.setRoles(new HashSet<>());
created.setTenantId(tenantId);
return created;
});
if (user.getId() == null) {
user.setPassword(passwordEncoder.encode(rawPassword));
}
user.setTenantId(tenantId);
addMissingByCode(user.getRoles(), roles, Role::getCode);
userRepository.save(user);
}
private void getOrCreateModule(String code, String name, boolean enabled) {
moduleRepository.findByTenantIdAndCode(TenantContext.getRequiredTenantId(), code)
.orElseGet(() -> {
SystemModule module = new SystemModule();
module.setCode(code);
module.setName(name);
module.setEnabled(enabled);
module.setTenantId(TenantContext.getRequiredTenantId());
return moduleRepository.save(module);
});
}
private <T> void addMissingByCode(Collection<T> target, Collection<T> requested, java.util.function.Function<T, String> codeExtractor) {
Set<String> existingCodes = target.stream()
.map(codeExtractor)
.collect(Collectors.toSet());
List<T> missing = requested.stream()
.filter(item -> item != null && !existingCodes.contains(codeExtractor.apply(item)))
.toList();
target.addAll(missing);
}
}

View File

@ -0,0 +1,20 @@
package id.iptek.utms.core.config;
import org.springframework.context.MessageSource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.support.ReloadableResourceBundleMessageSource;
@Configuration
public class I18nConfig {
@Bean
public MessageSource messageSource() {
ReloadableResourceBundleMessageSource source = new ReloadableResourceBundleMessageSource();
source.setBasename("classpath:i18n/messages");
source.setDefaultEncoding("UTF-8");
source.setFallbackToSystemLocale(false);
return source;
}
}

View File

@ -0,0 +1,20 @@
package id.iptek.utms.core.config;
import id.iptek.utms.core.security.SecurityUtils;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.domain.AuditorAware;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
import java.util.Optional;
@Configuration
@EnableJpaAuditing
public class JpaAuditConfig {
@Bean
public AuditorAware<String> auditorAware() {
return () -> Optional.ofNullable(SecurityUtils.currentUsername()).or(() -> Optional.of("system"));
}
}

View File

@ -0,0 +1,20 @@
package id.iptek.utms.core.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.LocaleResolver;
import org.springframework.web.servlet.i18n.AcceptHeaderLocaleResolver;
import java.util.Locale;
@Configuration
public class LocaleConfig {
@Bean
public LocaleResolver localeResolver() {
AcceptHeaderLocaleResolver resolver = new AcceptHeaderLocaleResolver();
resolver.setDefaultLocale(Locale.ENGLISH);
return resolver;
}
}

View File

@ -0,0 +1,36 @@
package id.iptek.utms.core.config;
import io.swagger.v3.oas.models.Components;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.security.SecurityRequirement;
import io.swagger.v3.oas.models.security.SecurityScheme;
import io.swagger.v3.oas.models.security.SecurityScheme.In;
import io.swagger.v3.oas.models.security.SecurityScheme.Type;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class OpenApiConfig {
@Bean
public OpenAPI customOpenAPI() {
final String securitySchemeName = "bearerAuth";
return new OpenAPI()
.info(new Info()
.title("UTMS NG BE API")
.version("1.0.0")
.description("Authentication: click Authorize and paste only the raw JWT. UI will send 'Authorization: Bearer <token>'."))
.addSecurityItem(new SecurityRequirement().addList(securitySchemeName))
.components(new Components().addSecuritySchemes(securitySchemeName,
new SecurityScheme()
.name("Authorization")
.type(Type.HTTP)
.in(In.HEADER)
.scheme("bearer")
.bearerFormat("JWT")
));
}
}

View File

@ -0,0 +1,31 @@
package id.iptek.utms.core.config;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import java.time.Duration;
@Configuration
@EnableCaching
public class RedisConfig {
@Bean
public CacheManager cacheManager(RedisConnectionFactory connectionFactory) {
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(10))
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
.disableCachingNullValues();
return RedisCacheManager.builder(connectionFactory)
.cacheDefaults(config)
.build();
}
}

View File

@ -0,0 +1,48 @@
package id.iptek.utms.core.domain;
import id.iptek.utms.tenant.TenantContext;
import jakarta.persistence.Column;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.MappedSuperclass;
import lombok.Getter;
import lombok.Setter;
import org.springframework.data.annotation.CreatedBy;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedBy;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.Instant;
@Getter
@Setter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseEntity {
@Column(name = "tenant_id", nullable = false, updatable = false)
private String tenantId;
@CreatedDate
@Column(name = "created_at", nullable = false, updatable = false)
private Instant createdAt;
@LastModifiedDate
@Column(name = "updated_at", nullable = false)
private Instant updatedAt;
@CreatedBy
@Column(name = "created_by")
private String createdBy;
@LastModifiedBy
@Column(name = "updated_by")
private String updatedBy;
protected void applyTenantFromContext() {
if (tenantId == null) {
tenantId = TenantContext.getRequiredTenantId();
}
}
}

View File

@ -0,0 +1,14 @@
package id.iptek.utms.core.domain;
import jakarta.persistence.PrePersist;
public class TenantEntityListener {
@PrePersist
public void prePersist(Object entity) {
if (entity instanceof BaseEntity baseEntity) {
baseEntity.applyTenantFromContext();
}
}
}

View File

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

View File

@ -0,0 +1,53 @@
package id.iptek.utms.core.exception;
import id.iptek.utms.api.ApiResponse;
import id.iptek.utms.core.i18n.MessageResolver;
import jakarta.validation.ConstraintViolationException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class GlobalExceptionHandler {
private final MessageResolver messageResolver;
public GlobalExceptionHandler(MessageResolver messageResolver) {
this.messageResolver = messageResolver;
}
@ExceptionHandler(AppException.class)
public ResponseEntity<ApiResponse<Void>> handleAppException(AppException ex) {
return ResponseEntity.badRequest().body(ApiResponse.fail(ex.getMessage()));
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiResponse<Void>> handleValidation(MethodArgumentNotValidException ex) {
String errorMessage = ex.getBindingResult().getFieldErrors().stream()
.findFirst()
.map(err -> err.getField() + " " + err.getDefaultMessage())
.orElse(messageResolver.get("error.validation"));
return ResponseEntity.badRequest().body(ApiResponse.fail(errorMessage));
}
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<ApiResponse<Void>> handleConstraintViolation(ConstraintViolationException ex) {
return ResponseEntity.badRequest().body(ApiResponse.fail(ex.getMessage()));
}
@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<ApiResponse<Void>> handleAccessDenied() {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.fail(messageResolver.get("error.forbidden")));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<Void>> handleGeneralException(Exception ex) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.fail(messageResolver.get("error.internal")));
}
}

View File

@ -0,0 +1,20 @@
package id.iptek.utms.core.i18n;
import org.springframework.context.MessageSource;
import org.springframework.context.i18n.LocaleContextHolder;
import org.springframework.stereotype.Component;
@Component
public class MessageResolver {
private final MessageSource messageSource;
public MessageResolver(MessageSource messageSource) {
this.messageSource = messageSource;
}
public String get(String key, Object... args) {
return messageSource.getMessage(key, args, LocaleContextHolder.getLocale());
}
}

View File

@ -0,0 +1,19 @@
package id.iptek.utms.core.security;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
public final class SecurityUtils {
private SecurityUtils() {
}
public static String currentUsername() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null || !authentication.isAuthenticated()) {
return null;
}
return authentication.getName();
}
}

View File

@ -0,0 +1,14 @@
package id.iptek.utms.messaging;
import java.io.Serializable;
import java.util.UUID;
public record ApprovalCompletedEvent(
UUID requestId,
String tenantId,
String resourceType,
String resourceId,
String approvedBy
) implements Serializable {
}

View File

@ -0,0 +1,27 @@
package id.iptek.utms.messaging;
import id.iptek.utms.auth.service.UserRoleManagementService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.jms.annotation.JmsListener;
import org.springframework.stereotype.Component;
@Component
public class ApprovalEventConsumer {
private static final Logger log = LoggerFactory.getLogger(ApprovalEventConsumer.class);
private final UserRoleManagementService userRoleManagementService;
public ApprovalEventConsumer(UserRoleManagementService userRoleManagementService) {
this.userRoleManagementService = userRoleManagementService;
}
@JmsListener(destination = "approval.completed.queue")
public void onApprovalCompleted(ApprovalCompletedEvent event) {
log.info("Received approval.completed event requestId={} tenant={} resourceType={}",
event.requestId(), event.tenantId(), event.resourceType());
userRoleManagementService.applyApprovedRequest(event);
}
}

View File

@ -0,0 +1,19 @@
package id.iptek.utms.messaging;
import org.springframework.jms.core.JmsTemplate;
import org.springframework.stereotype.Component;
@Component
public class ApprovalEventProducer {
private final JmsTemplate jmsTemplate;
public ApprovalEventProducer(JmsTemplate jmsTemplate) {
this.jmsTemplate = jmsTemplate;
}
public void publishCompleted(ApprovalCompletedEvent event) {
jmsTemplate.convertAndSend("approval.completed.queue", event);
}
}

View File

@ -0,0 +1,43 @@
package id.iptek.utms.module.controller;
import id.iptek.utms.api.ApiResponse;
import id.iptek.utms.core.i18n.MessageResolver;
import id.iptek.utms.module.dto.ModuleResponse;
import id.iptek.utms.module.dto.ModuleToggleRequest;
import id.iptek.utms.module.service.ModuleRegistryService;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/modules")
@SecurityRequirement(name = "bearerAuth")
public class ModuleController {
private final ModuleRegistryService moduleRegistryService;
private final MessageResolver messageResolver;
public ModuleController(ModuleRegistryService moduleRegistryService, MessageResolver messageResolver) {
this.moduleRegistryService = moduleRegistryService;
this.messageResolver = messageResolver;
}
@GetMapping
@PreAuthorize("hasRole('ADMIN')")
public ApiResponse<List<ModuleResponse>> list() {
return ApiResponse.ok(messageResolver.get("module.list.success"), moduleRegistryService.listModules());
}
@PostMapping("/{code}/toggle")
@PreAuthorize("hasRole('ADMIN')")
public ApiResponse<ModuleResponse> toggle(@PathVariable String code,
@RequestBody ModuleToggleRequest request,
HttpServletRequest servletRequest) {
return ApiResponse.ok(messageResolver.get("module.toggle.success"),
moduleRegistryService.setEnabled(code, request.enabled(), servletRequest));
}
}

View File

@ -0,0 +1,35 @@
package id.iptek.utms.module.domain;
import id.iptek.utms.core.domain.BaseEntity;
import id.iptek.utms.core.domain.TenantEntityListener;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import org.hibernate.annotations.Filter;
import java.util.UUID;
@Getter
@Setter
@Entity
@EntityListeners(TenantEntityListener.class)
@Filter(name = "tenantFilter", condition = "tenant_id = :tenantId")
@Table(name = "sys_system_modules", uniqueConstraints = {
@UniqueConstraint(name = "sys_uk_system_modules_tenant_code", columnNames = {"tenant_id", "code"})
})
public class SystemModule extends BaseEntity {
@Id
@GeneratedValue
private UUID id;
@Column(nullable = false)
private String code;
@Column(nullable = false)
private String name;
@Column(nullable = false)
private boolean enabled;
}

View File

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

View File

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

View File

@ -0,0 +1,14 @@
package id.iptek.utms.module.repository;
import id.iptek.utms.module.domain.SystemModule;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
public interface SystemModuleRepository extends JpaRepository<SystemModule, UUID> {
Optional<SystemModule> findByTenantIdAndCode(String tenantId, String code);
List<SystemModule> findByTenantId(String tenantId);
}

View File

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

View File

@ -0,0 +1,87 @@
package id.iptek.utms.module.service;
import id.iptek.utms.core.exception.AppException;
import id.iptek.utms.core.audit.service.AuditTrailService;
import id.iptek.utms.module.domain.SystemModule;
import id.iptek.utms.module.dto.ModuleResponse;
import id.iptek.utms.module.repository.SystemModuleRepository;
import id.iptek.utms.tenant.TenantContext;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Map;
import java.util.LinkedHashMap;
import java.util.function.Function;
import java.util.stream.Collectors;
@Service
public class ModuleRegistryService {
private final SystemModuleRepository systemModuleRepository;
private final Map<String, Module> moduleHandlers;
private final AuditTrailService auditTrailService;
public ModuleRegistryService(SystemModuleRepository systemModuleRepository,
List<Module> moduleHandlers,
AuditTrailService auditTrailService) {
this.systemModuleRepository = systemModuleRepository;
this.moduleHandlers = moduleHandlers.stream().collect(Collectors.toMap(Module::code, Function.identity()));
this.auditTrailService = auditTrailService;
}
public List<ModuleResponse> listModules() {
String tenantId = TenantContext.getRequiredTenantId();
return systemModuleRepository.findByTenantId(tenantId).stream()
.map(module -> new ModuleResponse(module.getCode(), module.getName(), module.isEnabled()))
.toList();
}
@Transactional
public ModuleResponse setEnabled(String code, boolean enabled, HttpServletRequest request) {
String tenantId = TenantContext.getRequiredTenantId();
SystemModule module = systemModuleRepository.findByTenantIdAndCode(tenantId, code)
.orElseThrow(() -> new AppException("Module not found: " + code));
String beforeState = auditTrailService.toJson(moduleSnapshot(module));
module.setEnabled(enabled);
systemModuleRepository.save(module);
String afterState = auditTrailService.toJson(moduleSnapshot(module));
auditTrailService.record(
"MODULE_TOGGLE",
"MODULE",
"SystemModule",
module.getId() != null ? module.getId().toString() : code,
AuditTrailService.SUCCESS,
"Module toggle changed",
beforeState,
afterState,
null,
request
);
Module handler = moduleHandlers.get(code);
if (handler != null) {
if (enabled) {
handler.onEnabled(tenantId);
} else {
handler.onDisabled(tenantId);
}
}
return new ModuleResponse(module.getCode(), module.getName(), module.isEnabled());
}
private Map<String, Object> moduleSnapshot(SystemModule module) {
Map<String, Object> snapshot = new LinkedHashMap<>();
snapshot.put("id", module.getId() != null ? module.getId().toString() : null);
snapshot.put("tenantId", module.getTenantId());
snapshot.put("code", module.getCode());
snapshot.put("name", module.getName());
snapshot.put("enabled", module.isEnabled());
return snapshot;
}
}

View File

@ -0,0 +1,27 @@
package id.iptek.utms.module.service;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
@Component
public class NotificationModule implements Module {
private static final Logger log = LoggerFactory.getLogger(NotificationModule.class);
@Override
public String code() {
return "NOTIFICATION";
}
@Override
public void onEnabled(String tenantId) {
log.info("Notification module enabled for tenant={}", tenantId);
}
@Override
public void onDisabled(String tenantId) {
log.info("Notification module disabled for tenant={}", tenantId);
}
}

View File

@ -0,0 +1,41 @@
package id.iptek.utms.preference.domain;
import id.iptek.utms.core.domain.BaseEntity;
import id.iptek.utms.core.domain.TenantEntityListener;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import lombok.Getter;
import lombok.Setter;
import org.hibernate.annotations.Filter;
import java.util.UUID;
@Getter
@Setter
@Entity
@EntityListeners(TenantEntityListener.class)
@Filter(name = "tenantFilter", condition = "tenant_id = :tenantId")
@Table(name = "sec_user_ui_preferences", uniqueConstraints = {
@UniqueConstraint(name = "sec_uk_user_ui_preferences", columnNames = {"tenant_id", "user_id", "preference_key"})
})
public class UserUiPreference extends BaseEntity {
@Id
@GeneratedValue
private UUID id;
@Column(name = "user_id", nullable = false, updatable = false)
private UUID userId;
@Column(name = "preference_key", nullable = false, length = 255)
private String preferenceKey;
@Column(name = "value_json", nullable = false, columnDefinition = "text")
private String valueJson;
}

View File

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

View File

@ -0,0 +1,13 @@
package id.iptek.utms.preference.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import java.util.List;
public record TablePreferenceRequest(
@NotBlank String preferenceKey,
@NotEmpty List<@NotBlank String> visibleColumns
) {
}

View File

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

View File

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

View File

@ -0,0 +1,19 @@
package id.iptek.utms.preference.repository;
import id.iptek.utms.preference.domain.UserUiPreference;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
public interface UserUiPreferenceRepository extends JpaRepository<UserUiPreference, UUID> {
List<UserUiPreference> findByUserId(UUID userId);
Optional<UserUiPreference> findByUserIdAndPreferenceKey(UUID userId, String preferenceKey);
void deleteByUserIdAndPreferenceKey(UUID userId, String preferenceKey);
void deleteByUserId(UUID userId);
}

View File

@ -0,0 +1,187 @@
package id.iptek.utms.preference.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import id.iptek.utms.auth.security.UserPrincipal;
import id.iptek.utms.core.exception.AppException;
import id.iptek.utms.core.i18n.MessageResolver;
import id.iptek.utms.preference.domain.UserUiPreference;
import id.iptek.utms.preference.dto.TablePreferenceProfile;
import id.iptek.utms.preference.dto.TablePreferenceRequest;
import id.iptek.utms.preference.dto.TablePreferenceSavedProfile;
import id.iptek.utms.preference.dto.UserUiPreferencesResponse;
import id.iptek.utms.preference.repository.UserUiPreferenceRepository;
import id.iptek.utms.tenant.TenantContext;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.regex.Pattern;
@Service
public class UserPreferenceService {
private static final String VALUE_JSON_FIELD = "visibleColumns";
private static final Pattern PREFERENCE_KEY_PATTERN =
Pattern.compile("^(users|roles|workflow|audit|modules):[A-Za-z0-9_./-]+$");
private static final Map<String, List<String>> DEFAULT_COLUMNS_BY_KEY = Map.of(
"users:workflow-requests", List.of("id", "resourceType", "resourceId", "makerUsername", "status", "requiredSteps", "currentStep", "createdAt", "updatedAt", "actions"),
"workflow:requests", List.of("id", "resourceType", "resourceId", "makerUsername", "status", "updatedAt")
);
private final UserUiPreferenceRepository repository;
private final ObjectMapper objectMapper;
private final MessageResolver messageResolver;
public UserPreferenceService(UserUiPreferenceRepository repository,
ObjectMapper objectMapper,
MessageResolver messageResolver) {
this.repository = repository;
this.objectMapper = objectMapper;
this.messageResolver = messageResolver;
}
public UserUiPreferencesResponse getAll(Authentication authentication) {
UUID userId = getUserId(authentication);
TenantContext.getRequiredTenantId();
List<UserUiPreference> preferences = repository.findByUserId(userId);
preferences.sort(Comparator.comparing(UserUiPreference::getUpdatedAt, Comparator.nullsLast(Comparator.reverseOrder())));
List<TablePreferenceProfile> columns = preferences.stream()
.map(this::toProfile)
.toList();
Instant latestUpdatedAt = preferences.stream()
.map(UserUiPreference::getUpdatedAt)
.filter(Objects::nonNull)
.max(Instant::compareTo)
.orElse(null);
return new UserUiPreferencesResponse(columns, latestUpdatedAt);
}
@Transactional
public TablePreferenceSavedProfile upsert(Authentication authentication, TablePreferenceRequest request) {
UUID userId = getUserId(authentication);
String tenantId = TenantContext.getRequiredTenantId();
String normalizedKey = normalizePreferenceKey(request.preferenceKey());
List<String> normalizedColumns = normalizeVisibleColumns(request.visibleColumns());
UserUiPreference preference = repository.findByUserIdAndPreferenceKey(userId, normalizedKey)
.orElseGet(() -> {
UserUiPreference created = new UserUiPreference();
created.setUserId(userId);
created.setTenantId(tenantId);
created.setPreferenceKey(normalizedKey);
return created;
});
preference.setValueJson(serializePreferenceValue(normalizedColumns));
UserUiPreference saved = repository.save(preference);
return new TablePreferenceSavedProfile(
saved.getPreferenceKey(),
normalizedColumns,
saved.getUpdatedAt()
);
}
@Transactional
public TablePreferenceProfile resetTablePreference(Authentication authentication, String preferenceKey) {
UUID userId = getUserId(authentication);
String normalizedKey = normalizePreferenceKey(preferenceKey);
repository.deleteByUserIdAndPreferenceKey(userId, normalizedKey);
return new TablePreferenceProfile(normalizedKey, getDefaultColumns(normalizedKey));
}
@Transactional
public void resetAll(Authentication authentication) {
UUID userId = getUserId(authentication);
repository.deleteByUserId(userId);
}
private TablePreferenceProfile toProfile(UserUiPreference preference) {
return new TablePreferenceProfile(
preference.getPreferenceKey(),
parseVisibleColumns(preference.getValueJson())
);
}
private UUID getUserId(Authentication authentication) {
Object principal = authentication != null ? authentication.getPrincipal() : null;
if (principal instanceof UserPrincipal userPrincipal) {
return userPrincipal.getId();
}
throw new AppException(messageResolver.get("auth.invalid.credentials"));
}
private String normalizePreferenceKey(String preferenceKey) {
if (preferenceKey == null || preferenceKey.isBlank()) {
throw new AppException(messageResolver.get("user.preferences.invalid.key"));
}
String normalized = preferenceKey.trim();
if (!PREFERENCE_KEY_PATTERN.matcher(normalized).matches()) {
throw new AppException(messageResolver.get("user.preferences.invalid.key"));
}
return normalized;
}
private List<String> normalizeVisibleColumns(List<String> visibleColumns) {
if (visibleColumns == null || visibleColumns.isEmpty()) {
throw new AppException(messageResolver.get("user.preferences.invalid.columns"));
}
List<String> normalized = visibleColumns.stream()
.map(column -> column == null ? null : column.trim())
.filter(Objects::nonNull)
.toList();
if (normalized.isEmpty() || normalized.stream().anyMatch(String::isBlank)) {
throw new AppException(messageResolver.get("user.preferences.invalid.columns"));
}
return List.copyOf(normalized);
}
private String serializePreferenceValue(List<String> visibleColumns) {
try {
Map<String, List<String>> value = new LinkedHashMap<>();
value.put(VALUE_JSON_FIELD, visibleColumns);
return objectMapper.writeValueAsString(value);
} catch (Exception ex) {
throw new AppException(messageResolver.get("user.preferences.serialize.failed"));
}
}
private List<String> parseVisibleColumns(String valueJson) {
try {
JsonNode root = objectMapper.readTree(valueJson);
JsonNode columns = root.get(VALUE_JSON_FIELD);
if (columns == null || !columns.isArray()) {
throw new AppException(messageResolver.get("user.preferences.invalid.value"));
}
List<String> visibleColumns = new ArrayList<>();
for (JsonNode value : columns) {
String column = value != null ? value.asText() : null;
if (column == null || column.isBlank()) {
throw new AppException(messageResolver.get("user.preferences.invalid.value"));
}
visibleColumns.add(column);
}
return visibleColumns;
} catch (AppException ex) {
throw ex;
} catch (Exception ex) {
throw new AppException(messageResolver.get("user.preferences.invalid.value"));
}
}
private List<String> getDefaultColumns(String preferenceKey) {
return DEFAULT_COLUMNS_BY_KEY.getOrDefault(preferenceKey, List.of("id", "createdAt", "updatedAt", "actions"));
}
}

View File

@ -0,0 +1,33 @@
package id.iptek.utms.tenant;
import id.iptek.utms.core.domain.BaseEntity;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import java.time.Instant;
import java.util.UUID;
@Getter
@Setter
@Entity
@Table(name = "sys_tenants")
public class Tenant {
@Id
@GeneratedValue
private UUID id;
@Column(name = "tenant_id", nullable = false, unique = true)
private String tenantId;
@Column(nullable = false)
private String name;
@Column(nullable = false)
private boolean active = true;
@Column(name = "created_at", nullable = false, updatable = false)
private Instant createdAt = Instant.now();
}

View File

@ -0,0 +1,30 @@
package id.iptek.utms.tenant;
public final class TenantContext {
private static final ThreadLocal<String> TENANT = new ThreadLocal<>();
private TenantContext() {
}
public static void setTenantId(String tenantId) {
TENANT.set(tenantId);
}
public static String getTenantId() {
return TENANT.get();
}
public static String getRequiredTenantId() {
String tenantId = TENANT.get();
if (tenantId == null || tenantId.isBlank()) {
throw new IllegalStateException("Tenant context is not set");
}
return tenantId;
}
public static void clear() {
TENANT.remove();
}
}

View File

@ -0,0 +1,35 @@
package id.iptek.utms.tenant;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class TenantFilter extends OncePerRequestFilter {
public static final String TENANT_HEADER = "X-Tenant-Id";
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
try {
String tenantId = request.getHeader(TENANT_HEADER);
if (tenantId != null && !tenantId.isBlank()) {
TenantContext.setTenantId(tenantId);
}
filterChain.doFilter(request, response);
} finally {
TenantContext.clear();
}
}
}

View File

@ -0,0 +1,44 @@
package id.iptek.utms.tenant;
import jakarta.persistence.EntityManager;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.hibernate.Session;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
@Component
@Order(Ordered.HIGHEST_PRECEDENCE + 1)
public class TenantHibernateFilter extends OncePerRequestFilter {
private final EntityManager entityManager;
public TenantHibernateFilter(EntityManager entityManager) {
this.entityManager = entityManager;
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String tenantId = TenantContext.getTenantId();
Session session = entityManager.unwrap(Session.class);
if (tenantId != null && !tenantId.isBlank()) {
session.enableFilter("tenantFilter").setParameter("tenantId", tenantId);
}
try {
filterChain.doFilter(request, response);
} finally {
if (session.getEnabledFilter("tenantFilter") != null) {
session.disableFilter("tenantFilter");
}
}
}
}

View File

@ -0,0 +1,11 @@
package id.iptek.utms.tenant;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
import java.util.UUID;
public interface TenantRepository extends JpaRepository<Tenant, UUID> {
Optional<Tenant> findByTenantIdAndActiveTrue(String tenantId);
}

View File

@ -0,0 +1,22 @@
package id.iptek.utms.tenant;
import id.iptek.utms.core.exception.AppException;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
@Service
public class TenantService {
private final TenantRepository tenantRepository;
public TenantService(TenantRepository tenantRepository) {
this.tenantRepository = tenantRepository;
}
@Cacheable(cacheNames = "tenant:active", key = "#tenantId")
public Tenant getActiveTenant(String tenantId) {
return tenantRepository.findByTenantIdAndActiveTrue(tenantId)
.orElseThrow(() -> new AppException("Tenant is invalid or inactive"));
}
}

View File

@ -0,0 +1,71 @@
package id.iptek.utms.workflow.controller;
import id.iptek.utms.api.ApiResponse;
import id.iptek.utms.core.i18n.MessageResolver;
import id.iptek.utms.workflow.dto.ApprovalActionRequest;
import id.iptek.utms.workflow.dto.ApprovalResponse;
import id.iptek.utms.workflow.dto.CreateApprovalRequest;
import id.iptek.utms.workflow.dto.ApprovalRequestSummary;
import id.iptek.utms.workflow.service.ApprovalWorkflowService;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.UUID;
@RestController
@RequestMapping("/api/workflow")
@SecurityRequirement(name = "bearerAuth")
public class ApprovalWorkflowController {
private final ApprovalWorkflowService workflowService;
private final MessageResolver messageResolver;
public ApprovalWorkflowController(ApprovalWorkflowService workflowService, MessageResolver messageResolver) {
this.workflowService = workflowService;
this.messageResolver = messageResolver;
}
@PostMapping("/request")
@PreAuthorize("hasAuthority('WORKFLOW_CREATE') or hasRole('MAKER')")
public ApiResponse<ApprovalResponse> create(@Valid @RequestBody CreateApprovalRequest request,
HttpServletRequest servletRequest) {
return ApiResponse.ok(messageResolver.get("workflow.request.created"),
workflowService.createRequest(request, servletRequest));
}
@PostMapping("/{id}/approve")
@PreAuthorize("hasAuthority('WORKFLOW_APPROVE') or hasRole('CHECKER')")
public ApiResponse<ApprovalResponse> approve(@PathVariable UUID id,
@Valid @RequestBody ApprovalActionRequest request,
Authentication authentication,
HttpServletRequest servletRequest) {
return ApiResponse.ok(messageResolver.get("workflow.request.approved"),
workflowService.approve(id, request, authentication, servletRequest));
}
@PostMapping("/{id}/reject")
@PreAuthorize("hasAuthority('WORKFLOW_APPROVE') or hasRole('CHECKER')")
public ApiResponse<ApprovalResponse> reject(@PathVariable UUID id,
@Valid @RequestBody ApprovalActionRequest request,
Authentication authentication,
HttpServletRequest servletRequest) {
return ApiResponse.ok(messageResolver.get("workflow.request.rejected"),
workflowService.reject(id, request, authentication, servletRequest));
}
@GetMapping("/requests")
@PreAuthorize("hasAuthority('WORKFLOW_APPROVE') or hasRole('CHECKER') or hasRole('ADMIN')")
public ApiResponse<List<ApprovalRequestSummary>> listRequests(@RequestParam(required = false) String status,
@RequestParam(required = false) String resourceType,
@RequestParam(required = false) String makerUsername,
@RequestParam(defaultValue = "50") int limit) {
return ApiResponse.ok(messageResolver.get("workflow.request.listed"),
workflowService.listRequests(status, resourceType, makerUsername, limit));
}
}

View File

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

View File

@ -0,0 +1,38 @@
package id.iptek.utms.workflow.domain;
import id.iptek.utms.core.domain.BaseEntity;
import id.iptek.utms.core.domain.TenantEntityListener;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import org.hibernate.annotations.Filter;
import java.util.UUID;
@Getter
@Setter
@Entity
@EntityListeners(TenantEntityListener.class)
@Filter(name = "tenantFilter", condition = "tenant_id = :tenantId")
@Table(name = "sys_approval_history")
public class ApprovalHistory extends BaseEntity {
@Id
@GeneratedValue
private UUID id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "request_id", nullable = false)
private ApprovalRequest request;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private ApprovalAction action;
@Column(name = "actor_username", nullable = false)
private String actorUsername;
@Column(columnDefinition = "text")
private String notes;
}

View File

@ -0,0 +1,46 @@
package id.iptek.utms.workflow.domain;
import id.iptek.utms.core.domain.BaseEntity;
import id.iptek.utms.core.domain.TenantEntityListener;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import org.hibernate.annotations.Filter;
import java.util.UUID;
@Getter
@Setter
@Entity
@EntityListeners(TenantEntityListener.class)
@Filter(name = "tenantFilter", condition = "tenant_id = :tenantId")
@Table(name = "sys_approval_requests")
public class ApprovalRequest extends BaseEntity {
@Id
@GeneratedValue
private UUID id;
@Column(name = "resource_type", nullable = false)
private String resourceType;
@Column(name = "resource_id", nullable = false)
private String resourceId;
@Column(name = "payload", columnDefinition = "text")
private String payload;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private ApprovalStatus status = ApprovalStatus.DRAFT;
@Column(name = "required_steps", nullable = false)
private Integer requiredSteps = 1;
@Column(name = "current_step", nullable = false)
private Integer currentStep = 0;
@Column(name = "maker_username", nullable = false)
private String makerUsername;
}

View File

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

View File

@ -0,0 +1,38 @@
package id.iptek.utms.workflow.domain;
import id.iptek.utms.core.domain.BaseEntity;
import id.iptek.utms.core.domain.TenantEntityListener;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import org.hibernate.annotations.Filter;
import java.util.UUID;
@Getter
@Setter
@Entity
@EntityListeners(TenantEntityListener.class)
@Filter(name = "tenantFilter", condition = "tenant_id = :tenantId")
@Table(name = "sys_approval_steps")
public class ApprovalStep extends BaseEntity {
@Id
@GeneratedValue
private UUID id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "request_id", nullable = false)
private ApprovalRequest request;
@Column(name = "step_order", nullable = false)
private Integer stepOrder;
@Column(name = "checker_role", nullable = false)
private String checkerRole;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private ApprovalStatus status = ApprovalStatus.PENDING;
}

View File

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

View File

@ -0,0 +1,21 @@
package id.iptek.utms.workflow.dto;
import id.iptek.utms.workflow.domain.ApprovalStatus;
import java.time.Instant;
import java.util.UUID;
public record ApprovalRequestSummary(
UUID id,
String tenantId,
String resourceType,
String resourceId,
String makerUsername,
String payload,
ApprovalStatus status,
int requiredSteps,
int currentStep,
Instant createdAt,
Instant updatedAt
) {
}

View File

@ -0,0 +1,16 @@
package id.iptek.utms.workflow.dto;
import id.iptek.utms.workflow.domain.ApprovalStatus;
import java.util.UUID;
public record ApprovalResponse(
UUID id,
String resourceType,
String resourceId,
ApprovalStatus status,
int requiredSteps,
int currentStep
) {
}

View File

@ -0,0 +1,13 @@
package id.iptek.utms.workflow.dto;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
public record CreateApprovalRequest(
@NotBlank String resourceType,
@NotBlank String resourceId,
String payload,
@Min(1) int requiredSteps
) {
}

View File

@ -0,0 +1,10 @@
package id.iptek.utms.workflow.repository;
import id.iptek.utms.workflow.domain.ApprovalHistory;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.UUID;
public interface ApprovalHistoryRepository extends JpaRepository<ApprovalHistory, UUID> {
}

View File

@ -0,0 +1,34 @@
package id.iptek.utms.workflow.repository;
import id.iptek.utms.workflow.domain.ApprovalRequest;
import id.iptek.utms.workflow.domain.ApprovalStatus;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
public interface ApprovalRequestRepository extends JpaRepository<ApprovalRequest, UUID> {
Optional<ApprovalRequest> findByIdAndTenantId(UUID id, String tenantId);
List<ApprovalRequest> findByTenantIdAndStatus(String tenantId, ApprovalStatus status, Pageable pageable);
List<ApprovalRequest> findByTenantIdOrderByCreatedAtDesc(String tenantId, Pageable pageable);
@Query("""
SELECT r
FROM ApprovalRequest r
WHERE r.tenantId = :tenantId
AND (:status IS NULL OR r.status = :status)
AND (:resourceType IS NULL OR LOWER(r.resourceType) = LOWER(:resourceType))
AND (:makerUsername IS NULL OR LOWER(r.makerUsername) = LOWER(:makerUsername))
ORDER BY r.createdAt DESC
""")
List<ApprovalRequest> findByTenantIdWithFilters(@Param("tenantId") String tenantId,
@Param("status") ApprovalStatus status,
@Param("resourceType") String resourceType,
@Param("makerUsername") String makerUsername,
Pageable pageable);
}

View File

@ -0,0 +1,12 @@
package id.iptek.utms.workflow.repository;
import id.iptek.utms.workflow.domain.ApprovalStep;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
import java.util.UUID;
public interface ApprovalStepRepository extends JpaRepository<ApprovalStep, UUID> {
Optional<ApprovalStep> findByRequestIdAndTenantIdAndStepOrder(UUID requestId, String tenantId, Integer stepOrder);
}

View File

@ -0,0 +1,327 @@
package id.iptek.utms.workflow.service;
import id.iptek.utms.core.exception.AppException;
import id.iptek.utms.core.audit.service.AuditTrailService;
import id.iptek.utms.core.security.SecurityUtils;
import id.iptek.utms.messaging.ApprovalCompletedEvent;
import id.iptek.utms.messaging.ApprovalEventProducer;
import id.iptek.utms.tenant.TenantContext;
import id.iptek.utms.workflow.domain.*;
import id.iptek.utms.workflow.dto.ApprovalActionRequest;
import id.iptek.utms.workflow.dto.ApprovalResponse;
import id.iptek.utms.workflow.dto.ApprovalRequestSummary;
import id.iptek.utms.workflow.dto.CreateApprovalRequest;
import id.iptek.utms.workflow.repository.ApprovalHistoryRepository;
import id.iptek.utms.workflow.repository.ApprovalRequestRepository;
import id.iptek.utms.workflow.repository.ApprovalStepRepository;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import jakarta.servlet.http.HttpServletRequest;
import java.util.List;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Locale;
import java.util.UUID;
@Service
public class ApprovalWorkflowService {
private final ApprovalRequestRepository requestRepository;
private final ApprovalStepRepository stepRepository;
private final ApprovalHistoryRepository historyRepository;
private final ApprovalEventProducer eventProducer;
private final AuditTrailService auditTrailService;
public ApprovalWorkflowService(ApprovalRequestRepository requestRepository,
ApprovalStepRepository stepRepository,
ApprovalHistoryRepository historyRepository,
ApprovalEventProducer eventProducer,
AuditTrailService auditTrailService) {
this.requestRepository = requestRepository;
this.stepRepository = stepRepository;
this.historyRepository = historyRepository;
this.eventProducer = eventProducer;
this.auditTrailService = auditTrailService;
}
@Transactional
public ApprovalResponse createRequest(CreateApprovalRequest dto, HttpServletRequest httpServletRequest) {
return createRequest(dto, null, httpServletRequest);
}
@Transactional
public ApprovalResponse createRequest(CreateApprovalRequest dto, String checkerRole, HttpServletRequest httpServletRequest) {
return createRequest(dto.resourceType(), dto.resourceId(), dto.payload(), dto.requiredSteps(), checkerRole, httpServletRequest);
}
@Transactional
public ApprovalResponse createRequest(String resourceType,
String resourceId,
String payload,
int requiredSteps,
String checkerRole,
HttpServletRequest httpServletRequest) {
String tenantId = TenantContext.getRequiredTenantId();
String maker = SecurityUtils.currentUsername();
if (maker == null) {
throw new AppException("Authenticated maker is required");
}
String resolvedCheckerRole = (checkerRole == null || checkerRole.isBlank())
? "CHECKER"
: checkerRole;
ApprovalRequest request = new ApprovalRequest();
request.setTenantId(tenantId);
request.setResourceType(resourceType);
request.setResourceId(resourceId);
request.setPayload(payload);
request.setRequiredSteps(requiredSteps);
request.setCurrentStep(0);
request.setStatus(ApprovalStatus.PENDING);
request.setMakerUsername(maker);
ApprovalRequest saved = requestRepository.save(request);
for (int i = 1; i <= requiredSteps; i++) {
ApprovalStep step = new ApprovalStep();
step.setTenantId(tenantId);
step.setRequest(saved);
step.setStepOrder(i);
step.setCheckerRole(resolvedCheckerRole);
step.setStatus(ApprovalStatus.PENDING);
stepRepository.save(step);
}
addHistory(saved, ApprovalAction.CREATE, maker, "Request created and submitted");
auditTrailService.record(
"APPROVAL_REQUEST_CREATE",
"WORKFLOW",
"ApprovalRequest",
saved.getId().toString(),
AuditTrailService.SUCCESS,
"Approval request created",
null,
auditTrailService.toJson(snapshotApprovalRequest(saved)),
null,
httpServletRequest
);
return toResponse(saved);
}
@Transactional
public ApprovalResponse approve(UUID id, ApprovalActionRequest dto, Authentication auth, HttpServletRequest httpServletRequest) {
String tenantId = TenantContext.getRequiredTenantId();
String checker = auth.getName();
ApprovalRequest request = requestRepository.findByIdAndTenantId(id, tenantId)
.orElseThrow(() -> new AppException("Approval request not found"));
String beforeState = auditTrailService.toJson(snapshotApprovalRequest(request));
if (request.getStatus() != ApprovalStatus.PENDING) {
throw new AppException("Only pending request can be approved");
}
if (request.getMakerUsername().equals(checker)) {
throw new AppException("Maker cannot approve own request");
}
int nextStep = request.getCurrentStep() + 1;
ApprovalStep step = stepRepository
.findByRequestIdAndTenantIdAndStepOrder(request.getId(), tenantId, nextStep)
.orElseThrow(() -> new AppException("Approval step not found"));
String checkerRole = resolveCheckerRole(step.getCheckerRole(), dto == null ? null : dto.checkerRole());
validateCheckerRole(auth, checkerRole);
step.setStatus(ApprovalStatus.APPROVED);
request.setCurrentStep(nextStep);
if (nextStep >= request.getRequiredSteps()) {
request.setStatus(ApprovalStatus.APPROVED);
eventProducer.publishCompleted(new ApprovalCompletedEvent(
request.getId(),
tenantId,
request.getResourceType(),
request.getResourceId(),
checker
));
}
stepRepository.save(step);
ApprovalRequest saved = requestRepository.save(request);
auditTrailService.record(
"APPROVAL_REQUEST_APPROVE",
"WORKFLOW",
"ApprovalRequest",
request.getId().toString(),
AuditTrailService.SUCCESS,
"Approval request approved",
beforeState,
auditTrailService.toJson(snapshotApprovalRequest(saved)),
null,
httpServletRequest
);
addHistory(saved, ApprovalAction.APPROVE, checker, dto.notes());
return toResponse(saved);
}
@Transactional
public ApprovalResponse reject(UUID id, ApprovalActionRequest dto, Authentication auth, HttpServletRequest httpServletRequest) {
String tenantId = TenantContext.getRequiredTenantId();
String checker = auth.getName();
ApprovalRequest request = requestRepository.findByIdAndTenantId(id, tenantId)
.orElseThrow(() -> new AppException("Approval request not found"));
String beforeState = auditTrailService.toJson(snapshotApprovalRequest(request));
if (request.getStatus() != ApprovalStatus.PENDING) {
throw new AppException("Only pending request can be rejected");
}
if (request.getMakerUsername().equals(checker)) {
throw new AppException("Maker cannot reject own request");
}
int nextStep = request.getCurrentStep() + 1;
ApprovalStep step = stepRepository
.findByRequestIdAndTenantIdAndStepOrder(request.getId(), tenantId, nextStep)
.orElseThrow(() -> new AppException("Approval step not found"));
String checkerRole = resolveCheckerRole(step.getCheckerRole(), dto == null ? null : dto.checkerRole());
validateCheckerRole(auth, checkerRole);
step.setStatus(ApprovalStatus.REJECTED);
stepRepository.save(step);
request.setStatus(ApprovalStatus.REJECTED);
ApprovalRequest saved = requestRepository.save(request);
auditTrailService.record(
"APPROVAL_REQUEST_REJECT",
"WORKFLOW",
"ApprovalRequest",
request.getId().toString(),
AuditTrailService.SUCCESS,
"Approval request rejected",
beforeState,
auditTrailService.toJson(snapshotApprovalRequest(saved)),
null,
httpServletRequest
);
addHistory(saved, ApprovalAction.REJECT, checker, dto.notes());
return toResponse(saved);
}
@Transactional(readOnly = true)
public List<ApprovalRequestSummary> listRequests(String statusText, String resourceType, String makerUsername, int limit) {
String tenantId = TenantContext.getRequiredTenantId();
int maxLimit = Math.max(1, Math.min(limit, 200));
Pageable pageable = PageRequest.of(0, maxLimit, Sort.by(Sort.Direction.DESC, "createdAt"));
ApprovalStatus status = parseStatus(statusText);
List<ApprovalRequest> requests = requestRepository.findByTenantIdWithFilters(
tenantId,
status,
normalizeQueryParam(resourceType),
normalizeQueryParam(makerUsername),
pageable
);
return requests.stream().map(this::toSummary).toList();
}
private String normalizeQueryParam(String value) {
if (value == null || value.isBlank()) {
return null;
}
return value.trim();
}
private ApprovalStatus parseStatus(String statusText) {
if (statusText == null || statusText.isBlank()) {
return null;
}
try {
return ApprovalStatus.valueOf(statusText.trim().toUpperCase(Locale.ROOT));
} catch (IllegalArgumentException e) {
throw new AppException("Invalid status value: " + statusText);
}
}
private void validateCheckerRole(Authentication auth, String expectedRole) {
if (auth.getAuthorities().stream().map(GrantedAuthority::getAuthority).anyMatch("WORKFLOW_APPROVE"::equals)) {
return;
}
if (expectedRole == null || expectedRole.isBlank()) {
return;
}
String normalized = "ROLE_" + expectedRole.toUpperCase(Locale.ROOT);
boolean hasRole = auth.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.anyMatch(role -> role.equals(normalized));
if (!hasRole) {
throw new AppException("Checker does not have required role: " + expectedRole);
}
}
private String resolveCheckerRole(String stepRole, String overrideRole) {
if (overrideRole != null && !overrideRole.isBlank()) {
return overrideRole;
}
return stepRole;
}
private void addHistory(ApprovalRequest request, ApprovalAction action, String actor, String notes) {
ApprovalHistory history = new ApprovalHistory();
history.setTenantId(request.getTenantId());
history.setRequest(request);
history.setAction(action);
history.setActorUsername(actor);
history.setNotes(notes);
historyRepository.save(history);
}
private ApprovalResponse toResponse(ApprovalRequest request) {
return new ApprovalResponse(
request.getId(),
request.getResourceType(),
request.getResourceId(),
request.getStatus(),
request.getRequiredSteps(),
request.getCurrentStep()
);
}
private ApprovalRequestSummary toSummary(ApprovalRequest request) {
return new ApprovalRequestSummary(
request.getId(),
request.getTenantId(),
request.getResourceType(),
request.getResourceId(),
request.getMakerUsername(),
request.getPayload(),
request.getStatus(),
request.getRequiredSteps(),
request.getCurrentStep(),
request.getCreatedAt(),
request.getUpdatedAt()
);
}
private Map<String, Object> snapshotApprovalRequest(ApprovalRequest request) {
Map<String, Object> snapshot = new LinkedHashMap<>();
snapshot.put("id", request.getId() != null ? request.getId().toString() : null);
snapshot.put("tenantId", request.getTenantId());
snapshot.put("resourceType", request.getResourceType());
snapshot.put("resourceId", request.getResourceId());
snapshot.put("status", request.getStatus() != null ? request.getStatus().name() : null);
snapshot.put("requiredSteps", request.getRequiredSteps());
snapshot.put("currentStep", request.getCurrentStep());
snapshot.put("makerUsername", request.getMakerUsername());
snapshot.put("payload", request.getPayload());
return snapshot;
}
}