UserManagementController.java

package com.taxonomy.security.controller;

import com.taxonomy.security.model.AppRole;
import com.taxonomy.security.model.AppUser;
import com.taxonomy.security.repository.RoleRepository;
import com.taxonomy.security.repository.UserRepository;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.*;

import java.util.*;
import java.util.stream.Collectors;

/**
 * Admin-only REST API for managing application users.
 * <p>
 * All endpoints require {@code ROLE_ADMIN}. The last remaining admin user
 * cannot be disabled or have the ADMIN role removed — this prevents lockout.
 * <p>
 * Only active when local user management is enabled (without Keycloak).
 * In the Keycloak profile, user management is done in the Keycloak Admin Console.
 */
@RestController
@RequestMapping("/api/admin/users")
@PreAuthorize("hasRole('ADMIN')")
@Tag(name = "User Management", description = "Admin-only user CRUD operations")
@ConditionalOnProperty(name = "taxonomy.security.local-users-enabled",
        havingValue = "true", matchIfMissing = true)
public class UserManagementController {

    private static final Logger log = LoggerFactory.getLogger(UserManagementController.class);
    private static final int MIN_PASSWORD_LENGTH = 8;

    private final UserRepository userRepository;
    private final RoleRepository roleRepository;
    private final PasswordEncoder passwordEncoder;

    public UserManagementController(UserRepository userRepository,
                                    RoleRepository roleRepository,
                                    PasswordEncoder passwordEncoder) {
        this.userRepository = userRepository;
        this.roleRepository = roleRepository;
        this.passwordEncoder = passwordEncoder;
    }

    @GetMapping
    @Operation(summary = "List all users", description = "Returns all users without password hashes.")
    public List<Map<String, Object>> listUsers() {
        return userRepository.findAll().stream()
                .map(this::toUserMap)
                .collect(Collectors.toList());
    }

    @GetMapping("/{id}")
    @Operation(summary = "Get user by ID")
    public ResponseEntity<Map<String, Object>> getUser(@PathVariable Long id) {
        return userRepository.findById(id)
                .map(user -> ResponseEntity.ok(toUserMap(user)))
                .orElse(ResponseEntity.notFound().build());
    }

    @PostMapping
    @Operation(summary = "Create a new user")
    public ResponseEntity<Object> createUser(@RequestBody Map<String, Object> body,
                                             Authentication authentication) {
        String username = (String) body.get("username");
        String password = (String) body.get("password");
        String displayName = (String) body.get("displayName");
        String email = (String) body.get("email");
        @SuppressWarnings("unchecked")
        List<String> roles = (List<String>) body.get("roles");

        // Validation
        if (username == null || username.isBlank()) {
            return badRequest("Username is required.");
        }
        if (password == null || password.length() < MIN_PASSWORD_LENGTH) {
            return badRequest("Password must be at least " + MIN_PASSWORD_LENGTH + " characters.");
        }
        if (userRepository.findByUsername(username).isPresent()) {
            return ResponseEntity.status(HttpStatus.CONFLICT)
                    .body(Map.of("error", "Username '" + username + "' already exists."));
        }

        AppUser user = new AppUser();
        user.setUsername(username);
        user.setPasswordHash(passwordEncoder.encode(password));
        user.setEnabled(true);
        user.setDisplayName(displayName);
        user.setEmail(email);
        user.setRoles(resolveRoles(roles));
        userRepository.save(user);

        log.info("USER_CREATED user={} roles={} by={}", username,
                user.getRoles().stream().map(AppRole::getName).collect(Collectors.joining(",")),
                authentication.getName());

        return ResponseEntity.status(HttpStatus.CREATED).body(toUserMap(user));
    }

    @PutMapping("/{id}")
    @Operation(summary = "Update user details (roles, displayName, email, enabled)")
    public ResponseEntity<Object> updateUser(@PathVariable Long id,
                                             @RequestBody Map<String, Object> body,
                                             Authentication authentication) {
        Optional<AppUser> optionalUser = userRepository.findById(id);
        if (optionalUser.isEmpty()) {
            return ResponseEntity.notFound().build();
        }

        AppUser user = optionalUser.get();

        if (body.containsKey("displayName")) {
            user.setDisplayName((String) body.get("displayName"));
        }
        if (body.containsKey("email")) {
            user.setEmail((String) body.get("email"));
        }
        if (body.containsKey("enabled")) {
            boolean enabled = (Boolean) body.get("enabled");
            if (!enabled && isLastAdmin(user)) {
                return badRequest("Cannot disable the last admin user.");
            }
            user.setEnabled(enabled);
        }
        if (body.containsKey("roles")) {
            @SuppressWarnings("unchecked")
            List<String> roles = (List<String>) body.get("roles");
            Set<AppRole> newRoles = resolveRoles(roles);
            boolean removingAdmin = user.getRoles().stream()
                    .anyMatch(r -> r.getName().equals("ROLE_ADMIN"))
                    && newRoles.stream().noneMatch(r -> r.getName().equals("ROLE_ADMIN"));
            if (removingAdmin && isLastAdmin(user)) {
                return badRequest("Cannot remove ADMIN role from the last admin user.");
            }
            user.setRoles(newRoles);
        }

        userRepository.save(user);
        log.info("USER_UPDATED user={} by={}", user.getUsername(), authentication.getName());

        return ResponseEntity.ok(toUserMap(user));
    }

    @PutMapping("/{id}/password")
    @Operation(summary = "Change a user's password (admin action)")
    public ResponseEntity<Object> changePassword(@PathVariable Long id,
                                                 @RequestBody Map<String, String> body,
                                                 Authentication authentication) {
        Optional<AppUser> optionalUser = userRepository.findById(id);
        if (optionalUser.isEmpty()) {
            return ResponseEntity.notFound().build();
        }

        String newPassword = body.get("password");
        if (newPassword == null || newPassword.length() < MIN_PASSWORD_LENGTH) {
            return badRequest("Password must be at least " + MIN_PASSWORD_LENGTH + " characters.");
        }

        AppUser user = optionalUser.get();
        user.setPasswordHash(passwordEncoder.encode(newPassword));
        userRepository.save(user);
        log.info("USER_PASSWORD_CHANGED user={} by={}", user.getUsername(), authentication.getName());

        return ResponseEntity.ok(Map.of("message", "Password changed successfully."));
    }

    @DeleteMapping("/{id}")
    @Operation(summary = "Disable a user (soft delete)")
    public ResponseEntity<Object> disableUser(@PathVariable Long id,
                                              Authentication authentication) {
        Optional<AppUser> optionalUser = userRepository.findById(id);
        if (optionalUser.isEmpty()) {
            return ResponseEntity.notFound().build();
        }

        AppUser user = optionalUser.get();
        if (isLastAdmin(user)) {
            return badRequest("Cannot disable the last admin user.");
        }

        user.setEnabled(false);
        userRepository.save(user);
        log.info("USER_DISABLED user={} by={}", user.getUsername(), authentication.getName());

        return ResponseEntity.ok(Map.of("message", "User '" + user.getUsername() + "' has been disabled."));
    }

    // ── Helpers ──────────────────────────────────────────────────────────────

    private boolean isLastAdmin(AppUser user) {
        boolean userIsAdmin = user.getRoles().stream()
                .anyMatch(r -> r.getName().equals("ROLE_ADMIN"));
        if (!userIsAdmin) {
            return false;
        }
        long adminCount = userRepository.findAll().stream()
                .filter(AppUser::isEnabled)
                .filter(u -> u.getRoles().stream().anyMatch(r -> r.getName().equals("ROLE_ADMIN")))
                .count();
        return adminCount <= 1;
    }

    private Set<AppRole> resolveRoles(List<String> roleNames) {
        if (roleNames == null || roleNames.isEmpty()) {
            // Default to USER role
            return roleRepository.findByName("ROLE_USER")
                    .map(Set::of)
                    .orElse(Set.of());
        }
        Set<AppRole> roles = new HashSet<>();
        for (String roleName : roleNames) {
            String normalized = roleName.startsWith("ROLE_") ? roleName : "ROLE_" + roleName;
            roleRepository.findByName(normalized).ifPresent(roles::add);
        }
        return roles;
    }

    private Map<String, Object> toUserMap(AppUser user) {
        Map<String, Object> map = new LinkedHashMap<>();
        map.put("id", user.getId());
        map.put("username", user.getUsername());
        map.put("displayName", user.getDisplayName());
        map.put("email", user.getEmail());
        map.put("enabled", user.isEnabled());
        map.put("roles", user.getRoles().stream()
                .map(AppRole::getName)
                .sorted()
                .collect(Collectors.toList()));
        return map;
    }

    private ResponseEntity<Object> badRequest(String message) {
        return ResponseEntity.badRequest().body(Map.of("error", message));
    }
}