mirror of
https://github.com/s-frick/effigenix.git
synced 2026-03-28 18:49:59 +01:00
refactor: restructure repository with separate backend and frontend directories
- Move Java backend to backend/ directory - Create frontend/ directory for TypeScript TUI and future WebUI - Update .gitignore for Node.js and worktrees - Update README.md with new repository structure - Copy documentation to backend/
This commit is contained in:
parent
ec9114aa0a
commit
c2c48a03e8
141 changed files with 734 additions and 9 deletions
25
backend/src/main/java/de/effigenix/EffigenixApplication.java
Normal file
25
backend/src/main/java/de/effigenix/EffigenixApplication.java
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
package de.effigenix;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
|
||||
import org.springframework.scheduling.annotation.EnableAsync;
|
||||
|
||||
/**
|
||||
* Main Application Class for Effigenix ERP System.
|
||||
*
|
||||
* Architecture: DDD + Clean Architecture
|
||||
* - Domain Layer: Pure business logic, no framework dependencies
|
||||
* - Application Layer: Use Cases (Transaction Script pattern for Generic Subdomains)
|
||||
* - Infrastructure Layer: Spring, JPA, Security, REST
|
||||
* - Shared Kernel: Cross-cutting concerns (AuthorizationPort, Result, etc.)
|
||||
*/
|
||||
@SpringBootApplication
|
||||
@EnableJpaAuditing
|
||||
@EnableAsync
|
||||
public class EffigenixApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(EffigenixApplication.class, args);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
package de.effigenix.application.usermanagement;
|
||||
|
||||
import de.effigenix.application.usermanagement.command.AssignRoleCommand;
|
||||
import de.effigenix.application.usermanagement.dto.UserDTO;
|
||||
import de.effigenix.domain.usermanagement.*;
|
||||
import de.effigenix.shared.common.Result;
|
||||
import de.effigenix.shared.security.ActorId;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import static de.effigenix.shared.common.Result.*;
|
||||
|
||||
/**
|
||||
* Use Case: Assign a role to a user.
|
||||
*/
|
||||
@Transactional
|
||||
public class AssignRole {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final RoleRepository roleRepository;
|
||||
private final AuditLogger auditLogger;
|
||||
|
||||
public AssignRole(
|
||||
UserRepository userRepository,
|
||||
RoleRepository roleRepository,
|
||||
AuditLogger auditLogger
|
||||
) {
|
||||
this.userRepository = userRepository;
|
||||
this.roleRepository = roleRepository;
|
||||
this.auditLogger = auditLogger;
|
||||
}
|
||||
|
||||
public Result<UserError, UserDTO> execute(AssignRoleCommand cmd, ActorId performedBy) {
|
||||
// 1. Find user
|
||||
UserId userId = UserId.of(cmd.userId());
|
||||
User user;
|
||||
switch (userRepository.findById(userId)) {
|
||||
case Failure<RepositoryError, Optional<User>> f ->
|
||||
{ return Result.failure(new UserError.RepositoryFailure(f.error().message())); }
|
||||
case Success<RepositoryError, Optional<User>> s -> {
|
||||
if (s.value().isEmpty()) {
|
||||
return Result.failure(new UserError.UserNotFound(userId));
|
||||
}
|
||||
user = s.value().get();
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Find role
|
||||
Role role;
|
||||
switch (roleRepository.findByName(cmd.roleName())) {
|
||||
case Failure<RepositoryError, Optional<Role>> f ->
|
||||
{ return Result.failure(new UserError.RepositoryFailure(f.error().message())); }
|
||||
case Success<RepositoryError, Optional<Role>> s -> {
|
||||
if (s.value().isEmpty()) {
|
||||
return Result.failure(new UserError.RoleNotFound(cmd.roleName()));
|
||||
}
|
||||
role = s.value().get();
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Assign role
|
||||
switch (user.assignRole(role)) {
|
||||
case Failure<UserError, Void> f -> { return Result.failure(f.error()); }
|
||||
case Success<UserError, Void> ignored -> { }
|
||||
}
|
||||
|
||||
switch (userRepository.save(user)) {
|
||||
case Failure<RepositoryError, Void> f ->
|
||||
{ return Result.failure(new UserError.RepositoryFailure(f.error().message())); }
|
||||
case Success<RepositoryError, Void> ignored -> { }
|
||||
}
|
||||
|
||||
// 4. Audit log
|
||||
auditLogger.log(AuditEvent.ROLE_ASSIGNED, "User: " + userId.value() + ", Role: " + role.name(), performedBy);
|
||||
|
||||
return Result.success(UserDTO.from(user));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
package de.effigenix.application.usermanagement;
|
||||
|
||||
/**
|
||||
* Audit events for HACCP/GoBD compliance.
|
||||
* All events are logged immutably with timestamp, actor, IP, user agent.
|
||||
*
|
||||
* Retention: 10 years (gesetzlich für HACCP/GoBD).
|
||||
*/
|
||||
public enum AuditEvent {
|
||||
// ==================== User Management ====================
|
||||
USER_CREATED,
|
||||
USER_UPDATED,
|
||||
USER_DELETED,
|
||||
USER_LOCKED,
|
||||
USER_UNLOCKED,
|
||||
USER_ACTIVATED,
|
||||
USER_DEACTIVATED,
|
||||
|
||||
ROLE_ASSIGNED,
|
||||
ROLE_REMOVED,
|
||||
|
||||
PASSWORD_CHANGED,
|
||||
PASSWORD_RESET,
|
||||
|
||||
LOGIN_SUCCESS,
|
||||
LOGIN_FAILED,
|
||||
LOGIN_BLOCKED, // User is locked
|
||||
LOGOUT,
|
||||
|
||||
// ==================== Quality BC (HACCP-relevant) ====================
|
||||
TEMPERATURE_RECORDED,
|
||||
TEMPERATURE_CRITICAL, // Critical limit exceeded
|
||||
CLEANING_PERFORMED,
|
||||
GOODS_INSPECTED,
|
||||
HACCP_REPORT_GENERATED,
|
||||
|
||||
// ==================== Production BC ====================
|
||||
BATCH_CREATED,
|
||||
BATCH_COMPLETED,
|
||||
RECIPE_CREATED,
|
||||
RECIPE_MODIFIED,
|
||||
RECIPE_DELETED,
|
||||
|
||||
// ==================== Inventory BC ====================
|
||||
STOCK_ADJUSTED,
|
||||
STOCK_MOVEMENT_RECORDED,
|
||||
INVENTORY_COUNT_PERFORMED,
|
||||
|
||||
// ==================== Procurement BC ====================
|
||||
PURCHASE_ORDER_CREATED,
|
||||
PURCHASE_ORDER_APPROVED,
|
||||
GOODS_RECEIVED,
|
||||
|
||||
// ==================== Sales BC ====================
|
||||
ORDER_CREATED,
|
||||
ORDER_COMPLETED,
|
||||
INVOICE_GENERATED,
|
||||
|
||||
// ==================== System ====================
|
||||
SYSTEM_SETTINGS_CHANGED,
|
||||
BRANCH_CREATED,
|
||||
BRANCH_MODIFIED
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
package de.effigenix.application.usermanagement;
|
||||
|
||||
import de.effigenix.shared.security.ActorId;
|
||||
|
||||
/**
|
||||
* Port for audit logging (HACCP/GoBD compliance).
|
||||
* Implementation will be in Infrastructure Layer (async database logging).
|
||||
*
|
||||
* All audit logs are immutable and retained for 10 years.
|
||||
*/
|
||||
public interface AuditLogger {
|
||||
|
||||
/**
|
||||
* Logs an audit event with entity details.
|
||||
*
|
||||
* @param event Event type
|
||||
* @param entityId ID of the entity affected (e.g., RecipeId, BatchId, UserId)
|
||||
* @param performedBy Actor who performed the action
|
||||
*/
|
||||
void log(AuditEvent event, String entityId, ActorId performedBy);
|
||||
|
||||
/**
|
||||
* Logs an audit event with free-form details.
|
||||
*
|
||||
* @param event Event type
|
||||
* @param details Additional details (e.g., error messages, critical values)
|
||||
*/
|
||||
void log(AuditEvent event, String details);
|
||||
|
||||
/**
|
||||
* Logs an audit event without entity (e.g., LOGIN_SUCCESS).
|
||||
*
|
||||
* @param event Event type
|
||||
* @param performedBy Actor who performed the action
|
||||
*/
|
||||
void log(AuditEvent event, ActorId performedBy);
|
||||
}
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
package de.effigenix.application.usermanagement;
|
||||
|
||||
import de.effigenix.application.usermanagement.command.AuthenticateCommand;
|
||||
import de.effigenix.application.usermanagement.dto.SessionToken;
|
||||
import de.effigenix.domain.usermanagement.*;
|
||||
import de.effigenix.shared.common.Result;
|
||||
import de.effigenix.shared.security.ActorId;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Optional;
|
||||
|
||||
import static de.effigenix.shared.common.Result.*;
|
||||
|
||||
/**
|
||||
* Use Case: Authenticate a user (login).
|
||||
*
|
||||
* Returns a JWT session token on success.
|
||||
* Logs all authentication attempts for security auditing.
|
||||
*/
|
||||
@Transactional
|
||||
public class AuthenticateUser {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final PasswordHasher passwordHasher;
|
||||
private final SessionManager sessionManager;
|
||||
private final AuditLogger auditLogger;
|
||||
|
||||
public AuthenticateUser(
|
||||
UserRepository userRepository,
|
||||
PasswordHasher passwordHasher,
|
||||
SessionManager sessionManager,
|
||||
AuditLogger auditLogger
|
||||
) {
|
||||
this.userRepository = userRepository;
|
||||
this.passwordHasher = passwordHasher;
|
||||
this.sessionManager = sessionManager;
|
||||
this.auditLogger = auditLogger;
|
||||
}
|
||||
|
||||
public Result<UserError, SessionToken> execute(AuthenticateCommand cmd) {
|
||||
// 1. Find user by username
|
||||
User user;
|
||||
switch (userRepository.findByUsername(cmd.username())) {
|
||||
case Failure<RepositoryError, Optional<User>> f ->
|
||||
{ return Result.failure(new UserError.RepositoryFailure(f.error().message())); }
|
||||
case Success<RepositoryError, Optional<User>> s -> {
|
||||
if (s.value().isEmpty()) {
|
||||
auditLogger.log(AuditEvent.LOGIN_FAILED, "Username not found: " + cmd.username());
|
||||
return Result.failure(new UserError.InvalidCredentials());
|
||||
}
|
||||
user = s.value().get();
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Check user status
|
||||
if (user.status() == UserStatus.LOCKED) {
|
||||
auditLogger.log(AuditEvent.LOGIN_BLOCKED, user.id().value(), ActorId.of(user.id().value()));
|
||||
return Result.failure(new UserError.UserLocked(user.id()));
|
||||
}
|
||||
|
||||
if (user.status() == UserStatus.INACTIVE) {
|
||||
auditLogger.log(AuditEvent.LOGIN_FAILED, "User inactive: " + user.username());
|
||||
return Result.failure(new UserError.UserInactive(user.id()));
|
||||
}
|
||||
|
||||
// 3. Verify password (BCrypt)
|
||||
if (!passwordHasher.verify(cmd.password(), user.passwordHash())) {
|
||||
auditLogger.log(AuditEvent.LOGIN_FAILED, user.id().value(), ActorId.of(user.id().value()));
|
||||
return Result.failure(new UserError.InvalidCredentials());
|
||||
}
|
||||
|
||||
// 4. Create JWT session
|
||||
SessionToken token = sessionManager.createSession(user);
|
||||
|
||||
// 5. Update last login timestamp
|
||||
user.updateLastLogin(LocalDateTime.now());
|
||||
switch (userRepository.save(user)) {
|
||||
case Failure<RepositoryError, Void> f ->
|
||||
{ return Result.failure(new UserError.RepositoryFailure(f.error().message())); }
|
||||
case Success<RepositoryError, Void> ignored -> { }
|
||||
}
|
||||
|
||||
// 6. Audit log
|
||||
auditLogger.log(AuditEvent.LOGIN_SUCCESS, user.id().value(), ActorId.of(user.id().value()));
|
||||
|
||||
return Result.success(token);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
package de.effigenix.application.usermanagement;
|
||||
|
||||
import de.effigenix.application.usermanagement.command.ChangePasswordCommand;
|
||||
import de.effigenix.domain.usermanagement.*;
|
||||
import de.effigenix.shared.common.Result;
|
||||
import de.effigenix.shared.security.ActorId;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import static de.effigenix.shared.common.Result.*;
|
||||
|
||||
/**
|
||||
* Use Case: Change user password.
|
||||
*
|
||||
* Requires current password verification for security.
|
||||
*/
|
||||
@Transactional
|
||||
public class ChangePassword {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final PasswordHasher passwordHasher;
|
||||
private final AuditLogger auditLogger;
|
||||
|
||||
public ChangePassword(
|
||||
UserRepository userRepository,
|
||||
PasswordHasher passwordHasher,
|
||||
AuditLogger auditLogger
|
||||
) {
|
||||
this.userRepository = userRepository;
|
||||
this.passwordHasher = passwordHasher;
|
||||
this.auditLogger = auditLogger;
|
||||
}
|
||||
|
||||
public Result<UserError, Void> execute(ChangePasswordCommand cmd, ActorId performedBy) {
|
||||
// 1. Find user
|
||||
UserId userId = UserId.of(cmd.userId());
|
||||
User user;
|
||||
switch (userRepository.findById(userId)) {
|
||||
case Failure<RepositoryError, Optional<User>> f ->
|
||||
{ return Result.failure(new UserError.RepositoryFailure(f.error().message())); }
|
||||
case Success<RepositoryError, Optional<User>> s -> {
|
||||
if (s.value().isEmpty()) {
|
||||
return Result.failure(new UserError.UserNotFound(userId));
|
||||
}
|
||||
user = s.value().get();
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Verify current password
|
||||
if (!passwordHasher.verify(cmd.currentPassword(), user.passwordHash())) {
|
||||
auditLogger.log(AuditEvent.PASSWORD_CHANGED, userId.value(), performedBy);
|
||||
return Result.failure(new UserError.InvalidCredentials());
|
||||
}
|
||||
|
||||
// 3. Validate new password
|
||||
if (!passwordHasher.isValidPassword(cmd.newPassword())) {
|
||||
return Result.failure(new UserError.InvalidPassword("Password must be at least 8 characters"));
|
||||
}
|
||||
|
||||
// 4. Hash new password
|
||||
PasswordHash newPasswordHash = passwordHasher.hash(cmd.newPassword());
|
||||
|
||||
// 5. Update user
|
||||
switch (user.changePassword(newPasswordHash)) {
|
||||
case Failure<UserError, Void> f -> { return Result.failure(f.error()); }
|
||||
case Success<UserError, Void> ignored -> { }
|
||||
}
|
||||
|
||||
switch (userRepository.save(user)) {
|
||||
case Failure<RepositoryError, Void> f ->
|
||||
{ return Result.failure(new UserError.RepositoryFailure(f.error().message())); }
|
||||
case Success<RepositoryError, Void> ignored -> { }
|
||||
}
|
||||
|
||||
// 6. Audit log
|
||||
auditLogger.log(AuditEvent.PASSWORD_CHANGED, user.id().value(), performedBy);
|
||||
|
||||
return Result.success(null);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
package de.effigenix.application.usermanagement;
|
||||
|
||||
import de.effigenix.application.usermanagement.command.CreateUserCommand;
|
||||
import de.effigenix.application.usermanagement.dto.UserDTO;
|
||||
import de.effigenix.domain.usermanagement.*;
|
||||
import de.effigenix.shared.common.Result;
|
||||
import de.effigenix.shared.security.ActorId;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
import static de.effigenix.shared.common.Result.*;
|
||||
|
||||
/**
|
||||
* Use Case: Create a new user account.
|
||||
*
|
||||
* Transaction Script Pattern (Generic Subdomain):
|
||||
* - Simple procedural logic
|
||||
* - No complex domain model
|
||||
* - Direct repository interaction
|
||||
*/
|
||||
@Transactional
|
||||
public class CreateUser {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final RoleRepository roleRepository;
|
||||
private final PasswordHasher passwordHasher;
|
||||
private final AuditLogger auditLogger;
|
||||
|
||||
public CreateUser(
|
||||
UserRepository userRepository,
|
||||
RoleRepository roleRepository,
|
||||
PasswordHasher passwordHasher,
|
||||
AuditLogger auditLogger
|
||||
) {
|
||||
this.userRepository = userRepository;
|
||||
this.roleRepository = roleRepository;
|
||||
this.passwordHasher = passwordHasher;
|
||||
this.auditLogger = auditLogger;
|
||||
}
|
||||
|
||||
public Result<UserError, UserDTO> execute(CreateUserCommand cmd, ActorId performedBy) {
|
||||
// 1. Validate password
|
||||
if (!passwordHasher.isValidPassword(cmd.password())) {
|
||||
return Result.failure(new UserError.InvalidPassword("Password must be at least 8 characters"));
|
||||
}
|
||||
|
||||
// 2. Check username uniqueness
|
||||
switch (userRepository.existsByUsername(cmd.username())) {
|
||||
case Failure<RepositoryError, Boolean> f ->
|
||||
{ return Result.failure(new UserError.RepositoryFailure(f.error().message())); }
|
||||
case Success<RepositoryError, Boolean> s -> {
|
||||
if (s.value()) {
|
||||
return Result.failure(new UserError.UsernameAlreadyExists(cmd.username()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Check email uniqueness
|
||||
switch (userRepository.existsByEmail(cmd.email())) {
|
||||
case Failure<RepositoryError, Boolean> f ->
|
||||
{ return Result.failure(new UserError.RepositoryFailure(f.error().message())); }
|
||||
case Success<RepositoryError, Boolean> s -> {
|
||||
if (s.value()) {
|
||||
return Result.failure(new UserError.EmailAlreadyExists(cmd.email()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Hash password (BCrypt)
|
||||
PasswordHash passwordHash = passwordHasher.hash(cmd.password());
|
||||
|
||||
// 5. Load roles
|
||||
Set<Role> roles = new HashSet<>();
|
||||
for (RoleName roleName : cmd.roleNames()) {
|
||||
switch (roleRepository.findByName(roleName)) {
|
||||
case Failure<RepositoryError, java.util.Optional<Role>> f ->
|
||||
{ return Result.failure(new UserError.RepositoryFailure(f.error().message())); }
|
||||
case Success<RepositoryError, java.util.Optional<Role>> s -> {
|
||||
if (s.value().isEmpty()) {
|
||||
return Result.failure(new UserError.RoleNotFound(roleName));
|
||||
}
|
||||
roles.add(s.value().get());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Create user entity (simple entity, not aggregate)
|
||||
switch (User.create(cmd.username(), cmd.email(), passwordHash, roles, cmd.branchId())) {
|
||||
case Failure<UserError, User> f ->
|
||||
{ return Result.failure(f.error()); }
|
||||
case Success<UserError, User> s -> {
|
||||
User user = s.value();
|
||||
|
||||
// 7. Save
|
||||
switch (userRepository.save(user)) {
|
||||
case Failure<RepositoryError, Void> f ->
|
||||
{ return Result.failure(new UserError.RepositoryFailure(f.error().message())); }
|
||||
case Success<RepositoryError, Void> ignored -> { }
|
||||
}
|
||||
|
||||
// 8. Audit log (HACCP/GoBD compliance)
|
||||
auditLogger.log(AuditEvent.USER_CREATED, user.id().value(), performedBy);
|
||||
|
||||
return Result.success(UserDTO.from(user));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
package de.effigenix.application.usermanagement;
|
||||
|
||||
import de.effigenix.application.usermanagement.dto.UserDTO;
|
||||
import de.effigenix.domain.usermanagement.*;
|
||||
import de.effigenix.shared.common.Result;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import static de.effigenix.shared.common.Result.*;
|
||||
|
||||
/**
|
||||
* Use Case: Get a single user by ID.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public class GetUser {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
|
||||
public GetUser(UserRepository userRepository) {
|
||||
this.userRepository = userRepository;
|
||||
}
|
||||
|
||||
public Result<UserError, UserDTO> execute(String userIdValue) {
|
||||
UserId userId = UserId.of(userIdValue);
|
||||
return switch (userRepository.findById(userId)) {
|
||||
case Failure<RepositoryError, Optional<User>> f ->
|
||||
Result.failure(new UserError.RepositoryFailure(f.error().message()));
|
||||
case Success<RepositoryError, Optional<User>> s ->
|
||||
s.value()
|
||||
.map(user -> Result.<UserError, UserDTO>success(UserDTO.from(user)))
|
||||
.orElse(Result.failure(new UserError.UserNotFound(userId)));
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
package de.effigenix.application.usermanagement;
|
||||
|
||||
import de.effigenix.application.usermanagement.dto.UserDTO;
|
||||
import de.effigenix.domain.usermanagement.RepositoryError;
|
||||
import de.effigenix.domain.usermanagement.User;
|
||||
import de.effigenix.domain.usermanagement.UserError;
|
||||
import de.effigenix.domain.usermanagement.UserRepository;
|
||||
import de.effigenix.shared.common.Result;
|
||||
import de.effigenix.shared.security.BranchId;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static de.effigenix.shared.common.Result.*;
|
||||
|
||||
/**
|
||||
* Use Case: List all users (with optional branch filtering).
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public class ListUsers {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
|
||||
public ListUsers(UserRepository userRepository) {
|
||||
this.userRepository = userRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists all users (admin view).
|
||||
*/
|
||||
public Result<UserError, List<UserDTO>> execute() {
|
||||
return switch (userRepository.findAll()) {
|
||||
case Failure<RepositoryError, List<User>> f ->
|
||||
Result.failure(new UserError.RepositoryFailure(f.error().message()));
|
||||
case Success<RepositoryError, List<User>> s ->
|
||||
Result.success(s.value().stream()
|
||||
.map(UserDTO::from)
|
||||
.collect(Collectors.toList()));
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists users for a specific branch (filtered view).
|
||||
*/
|
||||
public Result<UserError, List<UserDTO>> executeForBranch(BranchId branchId) {
|
||||
return switch (userRepository.findByBranchId(branchId.value())) {
|
||||
case Failure<RepositoryError, List<User>> f ->
|
||||
Result.failure(new UserError.RepositoryFailure(f.error().message()));
|
||||
case Success<RepositoryError, List<User>> s ->
|
||||
Result.success(s.value().stream()
|
||||
.map(UserDTO::from)
|
||||
.collect(Collectors.toList()));
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
package de.effigenix.application.usermanagement;
|
||||
|
||||
import de.effigenix.application.usermanagement.dto.UserDTO;
|
||||
import de.effigenix.domain.usermanagement.*;
|
||||
import de.effigenix.shared.common.Result;
|
||||
import de.effigenix.shared.security.ActorId;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import static de.effigenix.shared.common.Result.*;
|
||||
|
||||
/**
|
||||
* Use Case: Lock a user account (prevent login).
|
||||
*/
|
||||
@Transactional
|
||||
public class LockUser {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final AuditLogger auditLogger;
|
||||
|
||||
public LockUser(UserRepository userRepository, AuditLogger auditLogger) {
|
||||
this.userRepository = userRepository;
|
||||
this.auditLogger = auditLogger;
|
||||
}
|
||||
|
||||
public Result<UserError, UserDTO> execute(String userIdValue, ActorId performedBy) {
|
||||
UserId userId = UserId.of(userIdValue);
|
||||
User user;
|
||||
switch (userRepository.findById(userId)) {
|
||||
case Failure<RepositoryError, Optional<User>> f ->
|
||||
{ return Result.failure(new UserError.RepositoryFailure(f.error().message())); }
|
||||
case Success<RepositoryError, Optional<User>> s -> {
|
||||
if (s.value().isEmpty()) {
|
||||
return Result.failure(new UserError.UserNotFound(userId));
|
||||
}
|
||||
user = s.value().get();
|
||||
}
|
||||
}
|
||||
|
||||
user.lock();
|
||||
|
||||
switch (userRepository.save(user)) {
|
||||
case Failure<RepositoryError, Void> f ->
|
||||
{ return Result.failure(new UserError.RepositoryFailure(f.error().message())); }
|
||||
case Success<RepositoryError, Void> ignored -> { }
|
||||
}
|
||||
|
||||
auditLogger.log(AuditEvent.USER_LOCKED, user.id().value(), performedBy);
|
||||
|
||||
return Result.success(UserDTO.from(user));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
package de.effigenix.application.usermanagement;
|
||||
|
||||
import de.effigenix.domain.usermanagement.PasswordHash;
|
||||
|
||||
/**
|
||||
* Port for password hashing and verification.
|
||||
* Implementation will be in Infrastructure Layer (BCrypt with strength 12).
|
||||
*
|
||||
* Application Layer defines the contract, Infrastructure Layer implements it.
|
||||
*/
|
||||
public interface PasswordHasher {
|
||||
|
||||
/**
|
||||
* Hashes a plain-text password using BCrypt.
|
||||
*
|
||||
* @param plainPassword Plain-text password (never stored!)
|
||||
* @return BCrypt hash (60 characters, starts with $2a$, $2b$, or $2y$)
|
||||
*/
|
||||
PasswordHash hash(String plainPassword);
|
||||
|
||||
/**
|
||||
* Verifies a plain-text password against a BCrypt hash.
|
||||
*
|
||||
* @param plainPassword Plain-text password to verify
|
||||
* @param passwordHash BCrypt hash to compare against
|
||||
* @return true if password matches, false otherwise
|
||||
*/
|
||||
boolean verify(String plainPassword, PasswordHash passwordHash);
|
||||
|
||||
/**
|
||||
* Validates password strength (minimum requirements).
|
||||
*
|
||||
* @param plainPassword Plain-text password to validate
|
||||
* @return true if password meets requirements, false otherwise
|
||||
*/
|
||||
boolean isValidPassword(String plainPassword);
|
||||
}
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
package de.effigenix.application.usermanagement;
|
||||
|
||||
import de.effigenix.application.usermanagement.dto.UserDTO;
|
||||
import de.effigenix.domain.usermanagement.*;
|
||||
import de.effigenix.shared.common.Result;
|
||||
import de.effigenix.shared.security.ActorId;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import static de.effigenix.shared.common.Result.*;
|
||||
|
||||
/**
|
||||
* Use Case: Remove a role from a user.
|
||||
*
|
||||
* Allows administrators to revoke roles from users.
|
||||
* Role removal is immediate and affects user's permissions.
|
||||
*/
|
||||
@Transactional
|
||||
public class RemoveRole {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final RoleRepository roleRepository;
|
||||
private final AuditLogger auditLogger;
|
||||
|
||||
public RemoveRole(
|
||||
UserRepository userRepository,
|
||||
RoleRepository roleRepository,
|
||||
AuditLogger auditLogger
|
||||
) {
|
||||
this.userRepository = userRepository;
|
||||
this.roleRepository = roleRepository;
|
||||
this.auditLogger = auditLogger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a role from a user.
|
||||
*
|
||||
* @param userId User ID
|
||||
* @param roleName Role name to remove
|
||||
* @param performedBy Actor performing the action
|
||||
* @return Result with UserDTO or UserError
|
||||
*/
|
||||
public Result<UserError, UserDTO> execute(String userId, RoleName roleName, ActorId performedBy) {
|
||||
// 1. Find user
|
||||
UserId userIdObj = UserId.of(userId);
|
||||
User user;
|
||||
switch (userRepository.findById(userIdObj)) {
|
||||
case Failure<RepositoryError, Optional<User>> f ->
|
||||
{ return Result.failure(new UserError.RepositoryFailure(f.error().message())); }
|
||||
case Success<RepositoryError, Optional<User>> s -> {
|
||||
if (s.value().isEmpty()) {
|
||||
return Result.failure(new UserError.UserNotFound(userIdObj));
|
||||
}
|
||||
user = s.value().get();
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Find role
|
||||
Role role;
|
||||
switch (roleRepository.findByName(roleName)) {
|
||||
case Failure<RepositoryError, Optional<Role>> f ->
|
||||
{ return Result.failure(new UserError.RepositoryFailure(f.error().message())); }
|
||||
case Success<RepositoryError, Optional<Role>> s -> {
|
||||
if (s.value().isEmpty()) {
|
||||
return Result.failure(new UserError.RoleNotFound(roleName));
|
||||
}
|
||||
role = s.value().get();
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Remove role
|
||||
user.removeRole(role);
|
||||
|
||||
switch (userRepository.save(user)) {
|
||||
case Failure<RepositoryError, Void> f ->
|
||||
{ return Result.failure(new UserError.RepositoryFailure(f.error().message())); }
|
||||
case Success<RepositoryError, Void> ignored -> { }
|
||||
}
|
||||
|
||||
// 4. Audit log
|
||||
auditLogger.log(AuditEvent.ROLE_REMOVED, "User: " + userId + ", Role: " + roleName, performedBy);
|
||||
|
||||
return Result.success(UserDTO.from(user));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
package de.effigenix.application.usermanagement;
|
||||
|
||||
import de.effigenix.application.usermanagement.dto.SessionToken;
|
||||
import de.effigenix.domain.usermanagement.User;
|
||||
import de.effigenix.domain.usermanagement.UserId;
|
||||
|
||||
/**
|
||||
* Port for session management (JWT).
|
||||
* Implementation will be in Infrastructure Layer.
|
||||
*/
|
||||
public interface SessionManager {
|
||||
|
||||
/**
|
||||
* Creates a new session (JWT token) for a user.
|
||||
*
|
||||
* @param user User to create session for
|
||||
* @return Session token (access token + refresh token)
|
||||
*/
|
||||
SessionToken createSession(User user);
|
||||
|
||||
/**
|
||||
* Validates a JWT token and extracts the user ID.
|
||||
*
|
||||
* @param token JWT access token
|
||||
* @return UserId if valid
|
||||
* @throws RuntimeException if token is invalid or expired
|
||||
*/
|
||||
UserId validateToken(String token);
|
||||
|
||||
/**
|
||||
* Refreshes an expired access token using a refresh token.
|
||||
*
|
||||
* @param refreshToken Refresh token
|
||||
* @return New session token
|
||||
* @throws RuntimeException if refresh token is invalid or expired
|
||||
*/
|
||||
SessionToken refreshSession(String refreshToken);
|
||||
|
||||
/**
|
||||
* Invalidates a session (logout).
|
||||
*
|
||||
* @param token JWT access token to invalidate
|
||||
*/
|
||||
void invalidateSession(String token);
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
package de.effigenix.application.usermanagement;
|
||||
|
||||
import de.effigenix.application.usermanagement.dto.UserDTO;
|
||||
import de.effigenix.domain.usermanagement.*;
|
||||
import de.effigenix.shared.common.Result;
|
||||
import de.effigenix.shared.security.ActorId;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import static de.effigenix.shared.common.Result.*;
|
||||
|
||||
/**
|
||||
* Use Case: Unlock a user account (allow login).
|
||||
*/
|
||||
@Transactional
|
||||
public class UnlockUser {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final AuditLogger auditLogger;
|
||||
|
||||
public UnlockUser(UserRepository userRepository, AuditLogger auditLogger) {
|
||||
this.userRepository = userRepository;
|
||||
this.auditLogger = auditLogger;
|
||||
}
|
||||
|
||||
public Result<UserError, UserDTO> execute(String userIdValue, ActorId performedBy) {
|
||||
UserId userId = UserId.of(userIdValue);
|
||||
User user;
|
||||
switch (userRepository.findById(userId)) {
|
||||
case Failure<RepositoryError, Optional<User>> f ->
|
||||
{ return Result.failure(new UserError.RepositoryFailure(f.error().message())); }
|
||||
case Success<RepositoryError, Optional<User>> s -> {
|
||||
if (s.value().isEmpty()) {
|
||||
return Result.failure(new UserError.UserNotFound(userId));
|
||||
}
|
||||
user = s.value().get();
|
||||
}
|
||||
}
|
||||
|
||||
user.unlock();
|
||||
|
||||
switch (userRepository.save(user)) {
|
||||
case Failure<RepositoryError, Void> f ->
|
||||
{ return Result.failure(new UserError.RepositoryFailure(f.error().message())); }
|
||||
case Success<RepositoryError, Void> ignored -> { }
|
||||
}
|
||||
|
||||
auditLogger.log(AuditEvent.USER_UNLOCKED, user.id().value(), performedBy);
|
||||
|
||||
return Result.success(UserDTO.from(user));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
package de.effigenix.application.usermanagement;
|
||||
|
||||
import de.effigenix.application.usermanagement.command.UpdateUserCommand;
|
||||
import de.effigenix.application.usermanagement.dto.UserDTO;
|
||||
import de.effigenix.domain.usermanagement.*;
|
||||
import de.effigenix.shared.common.Result;
|
||||
import de.effigenix.shared.security.ActorId;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import static de.effigenix.shared.common.Result.*;
|
||||
|
||||
/**
|
||||
* Use Case: Update user details (email, branch).
|
||||
*/
|
||||
@Transactional
|
||||
public class UpdateUser {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final AuditLogger auditLogger;
|
||||
|
||||
public UpdateUser(UserRepository userRepository, AuditLogger auditLogger) {
|
||||
this.userRepository = userRepository;
|
||||
this.auditLogger = auditLogger;
|
||||
}
|
||||
|
||||
public Result<UserError, UserDTO> execute(UpdateUserCommand cmd, ActorId performedBy) {
|
||||
// 1. Find user
|
||||
UserId userId = UserId.of(cmd.userId());
|
||||
User user;
|
||||
switch (userRepository.findById(userId)) {
|
||||
case Failure<RepositoryError, Optional<User>> f ->
|
||||
{ return Result.failure(new UserError.RepositoryFailure(f.error().message())); }
|
||||
case Success<RepositoryError, Optional<User>> s -> {
|
||||
if (s.value().isEmpty()) {
|
||||
return Result.failure(new UserError.UserNotFound(userId));
|
||||
}
|
||||
user = s.value().get();
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Update email if provided
|
||||
if (cmd.email() != null && !cmd.email().equals(user.email())) {
|
||||
// Check email uniqueness
|
||||
switch (userRepository.existsByEmail(cmd.email())) {
|
||||
case Failure<RepositoryError, Boolean> f ->
|
||||
{ return Result.failure(new UserError.RepositoryFailure(f.error().message())); }
|
||||
case Success<RepositoryError, Boolean> s -> {
|
||||
if (s.value()) {
|
||||
return Result.failure(new UserError.EmailAlreadyExists(cmd.email()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch (user.updateEmail(cmd.email())) {
|
||||
case Failure<UserError, Void> f -> { return Result.failure(f.error()); }
|
||||
case Success<UserError, Void> ignored -> { }
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Update branch if provided
|
||||
if (cmd.branchId() != null && !cmd.branchId().equals(user.branchId())) {
|
||||
user.updateBranch(cmd.branchId());
|
||||
}
|
||||
|
||||
// 4. Save
|
||||
switch (userRepository.save(user)) {
|
||||
case Failure<RepositoryError, Void> f ->
|
||||
{ return Result.failure(new UserError.RepositoryFailure(f.error().message())); }
|
||||
case Success<RepositoryError, Void> ignored -> { }
|
||||
}
|
||||
|
||||
// 5. Audit log
|
||||
auditLogger.log(AuditEvent.USER_UPDATED, user.id().value(), performedBy);
|
||||
|
||||
return Result.success(UserDTO.from(user));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
package de.effigenix.application.usermanagement.command;
|
||||
|
||||
import de.effigenix.domain.usermanagement.RoleName;
|
||||
|
||||
/**
|
||||
* Command for assigning a role to a user.
|
||||
*/
|
||||
public record AssignRoleCommand(
|
||||
String userId,
|
||||
RoleName roleName
|
||||
) {
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
package de.effigenix.application.usermanagement.command;
|
||||
|
||||
/**
|
||||
* Command for user authentication (login).
|
||||
*/
|
||||
public record AuthenticateCommand(
|
||||
String username,
|
||||
String password // plain-text, will be verified against hash
|
||||
) {
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
package de.effigenix.application.usermanagement.command;
|
||||
|
||||
/**
|
||||
* Command for changing a user's password.
|
||||
*/
|
||||
public record ChangePasswordCommand(
|
||||
String userId,
|
||||
String currentPassword, // plain-text, for verification
|
||||
String newPassword // plain-text, will be hashed
|
||||
) {
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
package de.effigenix.application.usermanagement.command;
|
||||
|
||||
import de.effigenix.domain.usermanagement.RoleName;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Command for creating a new user.
|
||||
*/
|
||||
public record CreateUserCommand(
|
||||
String username,
|
||||
String email,
|
||||
String password, // plain-text, will be hashed
|
||||
Set<RoleName> roleNames,
|
||||
String branchId // optional, null for admin users
|
||||
) {
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
package de.effigenix.application.usermanagement.command;
|
||||
|
||||
/**
|
||||
* Command for updating user details.
|
||||
*/
|
||||
public record UpdateUserCommand(
|
||||
String userId,
|
||||
String email, // optional, null = no change
|
||||
String branchId // optional, null = no change
|
||||
) {
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
package de.effigenix.application.usermanagement.dto;
|
||||
|
||||
import de.effigenix.domain.usermanagement.Permission;
|
||||
import de.effigenix.domain.usermanagement.Role;
|
||||
import de.effigenix.domain.usermanagement.RoleName;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Data Transfer Object for Role.
|
||||
*/
|
||||
public record RoleDTO(
|
||||
String id,
|
||||
RoleName name,
|
||||
Set<Permission> permissions,
|
||||
String description
|
||||
) {
|
||||
/**
|
||||
* Maps a Role entity to a RoleDTO.
|
||||
*/
|
||||
public static RoleDTO from(Role role) {
|
||||
return new RoleDTO(
|
||||
role.id().value(),
|
||||
role.name(),
|
||||
role.permissions(),
|
||||
role.description()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
package de.effigenix.application.usermanagement.dto;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* JWT session token returned after successful authentication.
|
||||
*/
|
||||
public record SessionToken(
|
||||
String accessToken,
|
||||
String tokenType,
|
||||
long expiresIn, // in seconds
|
||||
LocalDateTime expiresAt,
|
||||
String refreshToken // for future refresh token support
|
||||
) {
|
||||
public static SessionToken create(String accessToken, long expiresInMs, String refreshToken) {
|
||||
return new SessionToken(
|
||||
accessToken,
|
||||
"Bearer",
|
||||
expiresInMs / 1000, // convert to seconds
|
||||
LocalDateTime.now().plusSeconds(expiresInMs / 1000),
|
||||
refreshToken
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
package de.effigenix.application.usermanagement.dto;
|
||||
|
||||
import de.effigenix.domain.usermanagement.User;
|
||||
import de.effigenix.domain.usermanagement.UserStatus;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Data Transfer Object for User.
|
||||
* Used in API responses and application layer.
|
||||
*/
|
||||
public record UserDTO(
|
||||
String id,
|
||||
String username,
|
||||
String email,
|
||||
Set<RoleDTO> roles,
|
||||
String branchId,
|
||||
UserStatus status,
|
||||
LocalDateTime createdAt,
|
||||
LocalDateTime lastLogin
|
||||
) {
|
||||
/**
|
||||
* Maps a User entity to a UserDTO.
|
||||
*/
|
||||
public static UserDTO from(User user) {
|
||||
return new UserDTO(
|
||||
user.id().value(),
|
||||
user.username(),
|
||||
user.email(),
|
||||
user.roles().stream()
|
||||
.map(RoleDTO::from)
|
||||
.collect(Collectors.toSet()),
|
||||
user.branchId(),
|
||||
user.status(),
|
||||
user.createdAt(),
|
||||
user.lastLogin()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
package de.effigenix.domain.filiales;
|
||||
|
||||
import de.effigenix.shared.security.Action;
|
||||
|
||||
/**
|
||||
* Type-safe actions for Filiales (Branch Management) Bounded Context.
|
||||
*
|
||||
* Example:
|
||||
* <pre>
|
||||
* authPort.assertCan(FilialesAction.BRANCH_WRITE);
|
||||
* </pre>
|
||||
*/
|
||||
public enum FilialesAction implements Action {
|
||||
// Branch Management
|
||||
BRANCH_READ,
|
||||
BRANCH_WRITE,
|
||||
BRANCH_DELETE
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
package de.effigenix.domain.inventory;
|
||||
|
||||
import de.effigenix.shared.security.Action;
|
||||
|
||||
/**
|
||||
* Type-safe actions for Inventory Management Bounded Context.
|
||||
*
|
||||
* Example:
|
||||
* <pre>
|
||||
* authPort.assertCan(InventoryAction.STOCK_READ);
|
||||
* </pre>
|
||||
*/
|
||||
public enum InventoryAction implements Action {
|
||||
// Stock Management
|
||||
STOCK_READ,
|
||||
STOCK_WRITE,
|
||||
|
||||
// Stock Movements
|
||||
STOCK_MOVEMENT_READ,
|
||||
STOCK_MOVEMENT_WRITE,
|
||||
|
||||
// Inventory Counts
|
||||
INVENTORY_COUNT_READ,
|
||||
INVENTORY_COUNT_WRITE
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
package de.effigenix.domain.labeling;
|
||||
|
||||
import de.effigenix.shared.security.Action;
|
||||
|
||||
/**
|
||||
* Type-safe actions for Labeling Bounded Context.
|
||||
*
|
||||
* Example:
|
||||
* <pre>
|
||||
* authPort.assertCan(LabelingAction.LABEL_PRINT);
|
||||
* </pre>
|
||||
*/
|
||||
public enum LabelingAction implements Action {
|
||||
// Label Management
|
||||
LABEL_READ,
|
||||
LABEL_WRITE,
|
||||
LABEL_PRINT
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
package de.effigenix.domain.procurement;
|
||||
|
||||
import de.effigenix.shared.security.Action;
|
||||
|
||||
/**
|
||||
* Type-safe actions for Procurement Bounded Context.
|
||||
*
|
||||
* Example:
|
||||
* <pre>
|
||||
* authPort.assertCan(ProcurementAction.PURCHASE_ORDER_WRITE);
|
||||
* </pre>
|
||||
*/
|
||||
public enum ProcurementAction implements Action {
|
||||
// Purchase Orders
|
||||
PURCHASE_ORDER_READ,
|
||||
PURCHASE_ORDER_WRITE,
|
||||
PURCHASE_ORDER_DELETE,
|
||||
|
||||
// Goods Receipt
|
||||
GOODS_RECEIPT_READ,
|
||||
GOODS_RECEIPT_WRITE,
|
||||
|
||||
// Supplier Management
|
||||
SUPPLIER_READ,
|
||||
SUPPLIER_WRITE,
|
||||
SUPPLIER_DELETE
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
package de.effigenix.domain.production;
|
||||
|
||||
import de.effigenix.shared.security.Action;
|
||||
|
||||
/**
|
||||
* Type-safe actions for Production Bounded Context.
|
||||
* Used with AuthorizationPort for domain-level authorization.
|
||||
*
|
||||
* Example:
|
||||
* <pre>
|
||||
* authPort.assertCan(ProductionAction.RECIPE_WRITE);
|
||||
* </pre>
|
||||
*/
|
||||
public enum ProductionAction implements Action {
|
||||
// Recipe Management
|
||||
RECIPE_READ,
|
||||
RECIPE_WRITE,
|
||||
RECIPE_DELETE,
|
||||
|
||||
// Batch Production
|
||||
BATCH_READ,
|
||||
BATCH_WRITE,
|
||||
BATCH_COMPLETE,
|
||||
BATCH_DELETE,
|
||||
|
||||
// Production Orders
|
||||
PRODUCTION_ORDER_READ,
|
||||
PRODUCTION_ORDER_WRITE,
|
||||
PRODUCTION_ORDER_DELETE
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
package de.effigenix.domain.quality;
|
||||
|
||||
import de.effigenix.shared.security.Action;
|
||||
|
||||
/**
|
||||
* Type-safe actions for Quality Management Bounded Context.
|
||||
* HACCP-relevant actions for compliance and auditing.
|
||||
*
|
||||
* Example:
|
||||
* <pre>
|
||||
* authPort.assertCan(QualityAction.TEMPERATURE_LOG_WRITE);
|
||||
* </pre>
|
||||
*/
|
||||
public enum QualityAction implements Action {
|
||||
// HACCP Management
|
||||
HACCP_READ,
|
||||
HACCP_WRITE,
|
||||
|
||||
// Temperature Monitoring
|
||||
TEMPERATURE_LOG_READ,
|
||||
TEMPERATURE_LOG_WRITE,
|
||||
|
||||
// Cleaning Records
|
||||
CLEANING_RECORD_READ,
|
||||
CLEANING_RECORD_WRITE,
|
||||
|
||||
// Goods Inspection
|
||||
GOODS_INSPECTION_READ,
|
||||
GOODS_INSPECTION_WRITE
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
package de.effigenix.domain.sales;
|
||||
|
||||
import de.effigenix.shared.security.Action;
|
||||
|
||||
/**
|
||||
* Type-safe actions for Sales Bounded Context.
|
||||
*
|
||||
* Example:
|
||||
* <pre>
|
||||
* authPort.assertCan(SalesAction.ORDER_WRITE);
|
||||
* </pre>
|
||||
*/
|
||||
public enum SalesAction implements Action {
|
||||
// Order Management
|
||||
ORDER_READ,
|
||||
ORDER_WRITE,
|
||||
ORDER_DELETE,
|
||||
|
||||
// Invoice Management
|
||||
INVOICE_READ,
|
||||
INVOICE_WRITE,
|
||||
INVOICE_DELETE,
|
||||
|
||||
// Customer Management
|
||||
CUSTOMER_READ,
|
||||
CUSTOMER_WRITE,
|
||||
CUSTOMER_DELETE
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
package de.effigenix.domain.usermanagement;
|
||||
|
||||
/**
|
||||
* Value Object representing a hashed password.
|
||||
* NEVER stores plain-text passwords!
|
||||
*
|
||||
* The hash should be created using BCrypt with strength 12.
|
||||
* Immutable and self-validating.
|
||||
*/
|
||||
public record PasswordHash(String value) {
|
||||
|
||||
public PasswordHash {
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new IllegalArgumentException("PasswordHash cannot be null or empty");
|
||||
}
|
||||
// BCrypt hashes start with $2a$, $2b$, or $2y$ and are 60 characters long
|
||||
if (!value.matches("^\\$2[ayb]\\$.{56}$")) {
|
||||
throw new IllegalArgumentException("Invalid BCrypt hash format");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a PasswordHash from a BCrypt hash string.
|
||||
* Use PasswordHasher in Infrastructure Layer to create hashes.
|
||||
*/
|
||||
public static PasswordHash of(String bcryptHash) {
|
||||
return new PasswordHash(bcryptHash);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
package de.effigenix.domain.usermanagement;
|
||||
|
||||
/**
|
||||
* Granular permissions for all Bounded Contexts.
|
||||
* Permissions are assigned to Roles, which are assigned to Users.
|
||||
*
|
||||
* Naming convention: {CONTEXT}_{ENTITY}_{ACTION}
|
||||
* - Context: PRODUCTION, QUALITY, INVENTORY, etc.
|
||||
* - Entity: RECIPE, BATCH, STOCK, etc.
|
||||
* - Action: READ, WRITE, DELETE, etc.
|
||||
*
|
||||
* These permissions are mapped from typsafe BC Actions via ActionToPermissionMapper.
|
||||
*/
|
||||
public enum Permission {
|
||||
// ==================== Production BC ====================
|
||||
RECIPE_READ,
|
||||
RECIPE_WRITE,
|
||||
RECIPE_DELETE,
|
||||
|
||||
BATCH_READ,
|
||||
BATCH_WRITE,
|
||||
BATCH_COMPLETE,
|
||||
BATCH_DELETE,
|
||||
|
||||
PRODUCTION_ORDER_READ,
|
||||
PRODUCTION_ORDER_WRITE,
|
||||
PRODUCTION_ORDER_DELETE,
|
||||
|
||||
// ==================== Quality BC ====================
|
||||
HACCP_READ,
|
||||
HACCP_WRITE,
|
||||
|
||||
TEMPERATURE_LOG_READ,
|
||||
TEMPERATURE_LOG_WRITE,
|
||||
|
||||
CLEANING_RECORD_READ,
|
||||
CLEANING_RECORD_WRITE,
|
||||
|
||||
GOODS_INSPECTION_READ,
|
||||
GOODS_INSPECTION_WRITE,
|
||||
|
||||
// ==================== Inventory BC ====================
|
||||
STOCK_READ,
|
||||
STOCK_WRITE,
|
||||
|
||||
STOCK_MOVEMENT_READ,
|
||||
STOCK_MOVEMENT_WRITE,
|
||||
|
||||
INVENTORY_COUNT_READ,
|
||||
INVENTORY_COUNT_WRITE,
|
||||
|
||||
// ==================== Procurement BC ====================
|
||||
PURCHASE_ORDER_READ,
|
||||
PURCHASE_ORDER_WRITE,
|
||||
PURCHASE_ORDER_DELETE,
|
||||
|
||||
GOODS_RECEIPT_READ,
|
||||
GOODS_RECEIPT_WRITE,
|
||||
|
||||
SUPPLIER_READ,
|
||||
SUPPLIER_WRITE,
|
||||
SUPPLIER_DELETE,
|
||||
|
||||
// ==================== Sales BC ====================
|
||||
ORDER_READ,
|
||||
ORDER_WRITE,
|
||||
ORDER_DELETE,
|
||||
|
||||
INVOICE_READ,
|
||||
INVOICE_WRITE,
|
||||
INVOICE_DELETE,
|
||||
|
||||
CUSTOMER_READ,
|
||||
CUSTOMER_WRITE,
|
||||
CUSTOMER_DELETE,
|
||||
|
||||
// ==================== Labeling BC ====================
|
||||
LABEL_READ,
|
||||
LABEL_WRITE,
|
||||
LABEL_PRINT,
|
||||
|
||||
// ==================== Filiales BC ====================
|
||||
BRANCH_READ,
|
||||
BRANCH_WRITE,
|
||||
BRANCH_DELETE,
|
||||
|
||||
// ==================== User Management BC ====================
|
||||
USER_READ,
|
||||
USER_WRITE,
|
||||
USER_DELETE,
|
||||
USER_LOCK,
|
||||
USER_UNLOCK,
|
||||
|
||||
ROLE_READ,
|
||||
ROLE_WRITE,
|
||||
ROLE_ASSIGN,
|
||||
ROLE_REMOVE,
|
||||
|
||||
// ==================== Reporting BC ====================
|
||||
REPORT_READ,
|
||||
REPORT_GENERATE,
|
||||
|
||||
// ==================== Notifications BC ====================
|
||||
NOTIFICATION_READ,
|
||||
NOTIFICATION_SEND,
|
||||
|
||||
// ==================== System ====================
|
||||
AUDIT_LOG_READ,
|
||||
SYSTEM_SETTINGS_READ,
|
||||
SYSTEM_SETTINGS_WRITE
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
package de.effigenix.domain.usermanagement;
|
||||
|
||||
/**
|
||||
* Repository operation errors.
|
||||
* Sealed interface ensures exhaustive handling.
|
||||
*/
|
||||
public sealed interface RepositoryError {
|
||||
|
||||
String message();
|
||||
|
||||
record EntityNotFound(String entityType, String id) implements RepositoryError {
|
||||
@Override
|
||||
public String message() {
|
||||
return entityType + " with ID '" + id + "' not found";
|
||||
}
|
||||
}
|
||||
|
||||
record DatabaseError(String message) implements RepositoryError {
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
package de.effigenix.domain.usermanagement;
|
||||
|
||||
import de.effigenix.shared.common.Result;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Role Entity (Reference Data).
|
||||
*
|
||||
* Roles are predefined and loaded from seed data.
|
||||
* Each Role has a set of Permissions that grant access to specific actions.
|
||||
*
|
||||
* Invariant: id is non-null, name is non-null
|
||||
*/
|
||||
public class Role {
|
||||
private final RoleId id;
|
||||
private final RoleName name;
|
||||
private Set<Permission> permissions;
|
||||
private String description;
|
||||
|
||||
private Role(
|
||||
RoleId id,
|
||||
RoleName name,
|
||||
Set<Permission> permissions,
|
||||
String description
|
||||
) {
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
this.permissions = permissions != null ? new HashSet<>(permissions) : new HashSet<>();
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory method for creating a new Role with validation.
|
||||
*/
|
||||
public static Result<UserError, Role> create(
|
||||
RoleName name,
|
||||
Set<Permission> permissions,
|
||||
String description
|
||||
) {
|
||||
if (name == null) {
|
||||
return Result.failure(new UserError.NullRole());
|
||||
}
|
||||
return Result.success(new Role(
|
||||
RoleId.generate(),
|
||||
name,
|
||||
permissions,
|
||||
description
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstitute from persistence - no validation.
|
||||
*/
|
||||
public static Role reconstitute(
|
||||
RoleId id,
|
||||
RoleName name,
|
||||
Set<Permission> permissions,
|
||||
String description
|
||||
) {
|
||||
return new Role(id, name, permissions, description);
|
||||
}
|
||||
|
||||
// ==================== Business Methods ====================
|
||||
|
||||
public Result<UserError, Void> addPermission(Permission permission) {
|
||||
if (permission == null) {
|
||||
return Result.failure(new UserError.NullRole());
|
||||
}
|
||||
this.permissions.add(permission);
|
||||
return Result.success(null);
|
||||
}
|
||||
|
||||
public void removePermission(Permission permission) {
|
||||
this.permissions.remove(permission);
|
||||
}
|
||||
|
||||
public void updateDescription(String newDescription) {
|
||||
this.description = newDescription;
|
||||
}
|
||||
|
||||
public boolean hasPermission(Permission permission) {
|
||||
return permissions.contains(permission);
|
||||
}
|
||||
|
||||
// ==================== Getters ====================
|
||||
|
||||
public RoleId id() { return id; }
|
||||
public RoleName name() { return name; }
|
||||
public Set<Permission> permissions() { return Collections.unmodifiableSet(permissions); }
|
||||
public String description() { return description; }
|
||||
|
||||
@Override
|
||||
public boolean equals(Object obj) {
|
||||
if (this == obj) return true;
|
||||
if (!(obj instanceof Role other)) return false;
|
||||
return id.equals(other.id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return id.hashCode();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "Role{id=" + id + ", name=" + name + ", permissions=" + permissions.size() + "}";
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
package de.effigenix.domain.usermanagement;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Value Object representing a Role's unique identifier.
|
||||
* Immutable and self-validating.
|
||||
*/
|
||||
public record RoleId(String value) {
|
||||
|
||||
public RoleId {
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new IllegalArgumentException("RoleId cannot be null or empty");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a new random RoleId.
|
||||
*/
|
||||
public static RoleId generate() {
|
||||
return new RoleId(UUID.randomUUID().toString());
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a RoleId from a string value.
|
||||
*/
|
||||
public static RoleId of(String value) {
|
||||
return new RoleId(value);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
package de.effigenix.domain.usermanagement;
|
||||
|
||||
/**
|
||||
* Predefined role names for the Effigenix ERP system.
|
||||
* Based on the 11 Bounded Contexts (7 Core, 3 Supporting, 3 Generic).
|
||||
*
|
||||
* Each role maps to a set of Permissions that control access to specific actions
|
||||
* across different Bounded Contexts.
|
||||
*/
|
||||
public enum RoleName {
|
||||
/**
|
||||
* System Administrator - full access to all features.
|
||||
*/
|
||||
ADMIN,
|
||||
|
||||
/**
|
||||
* Production Manager - manages recipes, batches, and production orders.
|
||||
* Permissions: RECIPE_*, BATCH_*, PRODUCTION_ORDER_*, STOCK_READ
|
||||
*/
|
||||
PRODUCTION_MANAGER,
|
||||
|
||||
/**
|
||||
* Production Worker - executes recipes and creates batches.
|
||||
* Permissions: RECIPE_READ, BATCH_READ/WRITE/COMPLETE, PRODUCTION_ORDER_READ
|
||||
*/
|
||||
PRODUCTION_WORKER,
|
||||
|
||||
/**
|
||||
* Quality Manager - HACCP compliance, quality assurance.
|
||||
* Permissions: HACCP_*, TEMPERATURE_LOG_*, CLEANING_RECORD_*, GOODS_INSPECTION_*
|
||||
*/
|
||||
QUALITY_MANAGER,
|
||||
|
||||
/**
|
||||
* Quality Inspector - records measurements and inspections.
|
||||
* Permissions: TEMPERATURE_LOG_*, CLEANING_RECORD_READ, GOODS_INSPECTION_*
|
||||
*/
|
||||
QUALITY_INSPECTOR,
|
||||
|
||||
/**
|
||||
* Procurement Manager - manages purchasing and suppliers.
|
||||
* Permissions: PURCHASE_ORDER_*, GOODS_RECEIPT_*, SUPPLIER_*, STOCK_READ
|
||||
*/
|
||||
PROCUREMENT_MANAGER,
|
||||
|
||||
/**
|
||||
* Warehouse Worker - manages inventory and stock.
|
||||
* Permissions: STOCK_*, STOCK_MOVEMENT_*, INVENTORY_COUNT_*
|
||||
*/
|
||||
WAREHOUSE_WORKER,
|
||||
|
||||
/**
|
||||
* Sales Manager - manages orders, invoices, and customers.
|
||||
* Permissions: ORDER_*, INVOICE_*, CUSTOMER_*, STOCK_READ
|
||||
*/
|
||||
SALES_MANAGER,
|
||||
|
||||
/**
|
||||
* Sales Staff - creates orders and views customers.
|
||||
* Permissions: ORDER_READ/WRITE, CUSTOMER_READ, STOCK_READ
|
||||
*/
|
||||
SALES_STAFF
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
package de.effigenix.domain.usermanagement;
|
||||
|
||||
import de.effigenix.shared.common.Result;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Repository interface for Role entity.
|
||||
* Roles are reference data - typically loaded from seed data.
|
||||
*/
|
||||
public interface RoleRepository {
|
||||
|
||||
Result<RepositoryError, Optional<Role>> findById(RoleId id);
|
||||
|
||||
Result<RepositoryError, Optional<Role>> findByName(RoleName name);
|
||||
|
||||
Result<RepositoryError, List<Role>> findAll();
|
||||
|
||||
Result<RepositoryError, Void> save(Role role);
|
||||
|
||||
Result<RepositoryError, Void> delete(Role role);
|
||||
|
||||
Result<RepositoryError, Boolean> existsByName(RoleName name);
|
||||
}
|
||||
|
|
@ -0,0 +1,215 @@
|
|||
package de.effigenix.domain.usermanagement;
|
||||
|
||||
import de.effigenix.shared.common.Result;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* User Entity (Simple Entity, NOT an Aggregate).
|
||||
*
|
||||
* Generic Subdomain → Minimal DDD:
|
||||
* - Validation via Result type in factory method
|
||||
* - NO complex business logic
|
||||
* - NO domain events
|
||||
*
|
||||
* Invariant: username is non-blank, email is valid, passwordHash is non-null, status is non-null
|
||||
*/
|
||||
public class User {
|
||||
private final UserId id;
|
||||
private String username;
|
||||
private String email;
|
||||
private PasswordHash passwordHash;
|
||||
private Set<Role> roles;
|
||||
private String branchId;
|
||||
private UserStatus status;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime lastLogin;
|
||||
|
||||
// Invariant: all fields validated via create() or reconstitute()
|
||||
private User(
|
||||
UserId id,
|
||||
String username,
|
||||
String email,
|
||||
PasswordHash passwordHash,
|
||||
Set<Role> roles,
|
||||
String branchId,
|
||||
UserStatus status,
|
||||
LocalDateTime createdAt,
|
||||
LocalDateTime lastLogin
|
||||
) {
|
||||
this.id = id;
|
||||
this.username = username;
|
||||
this.email = email;
|
||||
this.passwordHash = passwordHash;
|
||||
this.roles = roles != null ? new HashSet<>(roles) : new HashSet<>();
|
||||
this.branchId = branchId;
|
||||
this.status = status;
|
||||
this.createdAt = createdAt != null ? createdAt : LocalDateTime.now();
|
||||
this.lastLogin = lastLogin;
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory method for creating a new active user with validation.
|
||||
*/
|
||||
public static Result<UserError, User> create(
|
||||
String username,
|
||||
String email,
|
||||
PasswordHash passwordHash,
|
||||
Set<Role> roles,
|
||||
String branchId
|
||||
) {
|
||||
if (username == null || username.isBlank()) {
|
||||
return Result.failure(new UserError.InvalidUsername("Username cannot be null or empty"));
|
||||
}
|
||||
if (email == null || email.isBlank() || !isValidEmail(email)) {
|
||||
return Result.failure(new UserError.InvalidEmail(email != null ? email : "null"));
|
||||
}
|
||||
if (passwordHash == null) {
|
||||
return Result.failure(new UserError.NullPasswordHash());
|
||||
}
|
||||
|
||||
return Result.success(new User(
|
||||
UserId.generate(),
|
||||
username,
|
||||
email,
|
||||
passwordHash,
|
||||
roles,
|
||||
branchId,
|
||||
UserStatus.ACTIVE,
|
||||
LocalDateTime.now(),
|
||||
null
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstitute from persistence - no validation (data already validated on creation).
|
||||
*/
|
||||
public static User reconstitute(
|
||||
UserId id,
|
||||
String username,
|
||||
String email,
|
||||
PasswordHash passwordHash,
|
||||
Set<Role> roles,
|
||||
String branchId,
|
||||
UserStatus status,
|
||||
LocalDateTime createdAt,
|
||||
LocalDateTime lastLogin
|
||||
) {
|
||||
return new User(id, username, email, passwordHash, roles, branchId, status, createdAt, lastLogin);
|
||||
}
|
||||
|
||||
// ==================== Business Methods ====================
|
||||
|
||||
public void updateLastLogin(LocalDateTime timestamp) {
|
||||
this.lastLogin = timestamp;
|
||||
}
|
||||
|
||||
public Result<UserError, Void> changePassword(PasswordHash newPasswordHash) {
|
||||
if (newPasswordHash == null) {
|
||||
return Result.failure(new UserError.NullPasswordHash());
|
||||
}
|
||||
this.passwordHash = newPasswordHash;
|
||||
return Result.success(null);
|
||||
}
|
||||
|
||||
public void lock() {
|
||||
this.status = UserStatus.LOCKED;
|
||||
}
|
||||
|
||||
public void unlock() {
|
||||
this.status = UserStatus.ACTIVE;
|
||||
}
|
||||
|
||||
public void deactivate() {
|
||||
this.status = UserStatus.INACTIVE;
|
||||
}
|
||||
|
||||
public void activate() {
|
||||
this.status = UserStatus.ACTIVE;
|
||||
}
|
||||
|
||||
public Result<UserError, Void> assignRole(Role role) {
|
||||
if (role == null) {
|
||||
return Result.failure(new UserError.NullRole());
|
||||
}
|
||||
this.roles.add(role);
|
||||
return Result.success(null);
|
||||
}
|
||||
|
||||
public void removeRole(Role role) {
|
||||
this.roles.remove(role);
|
||||
}
|
||||
|
||||
public Result<UserError, Void> updateEmail(String newEmail) {
|
||||
if (newEmail == null || newEmail.isBlank()) {
|
||||
return Result.failure(new UserError.InvalidEmail("null or empty"));
|
||||
}
|
||||
if (!isValidEmail(newEmail)) {
|
||||
return Result.failure(new UserError.InvalidEmail(newEmail));
|
||||
}
|
||||
this.email = newEmail;
|
||||
return Result.success(null);
|
||||
}
|
||||
|
||||
public void updateBranch(String newBranchId) {
|
||||
this.branchId = newBranchId;
|
||||
}
|
||||
|
||||
public boolean isActive() {
|
||||
return status == UserStatus.ACTIVE;
|
||||
}
|
||||
|
||||
public boolean isLocked() {
|
||||
return status == UserStatus.LOCKED;
|
||||
}
|
||||
|
||||
public Set<Permission> getAllPermissions() {
|
||||
Set<Permission> allPermissions = new HashSet<>();
|
||||
for (Role role : roles) {
|
||||
allPermissions.addAll(role.permissions());
|
||||
}
|
||||
return Collections.unmodifiableSet(allPermissions);
|
||||
}
|
||||
|
||||
public boolean hasPermission(Permission permission) {
|
||||
return getAllPermissions().contains(permission);
|
||||
}
|
||||
|
||||
// ==================== Validation Helpers ====================
|
||||
|
||||
private static boolean isValidEmail(String email) {
|
||||
return email.matches("^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$");
|
||||
}
|
||||
|
||||
// ==================== Getters ====================
|
||||
|
||||
public UserId id() { return id; }
|
||||
public String username() { return username; }
|
||||
public String email() { return email; }
|
||||
public PasswordHash passwordHash() { return passwordHash; }
|
||||
public Set<Role> roles() { return Collections.unmodifiableSet(roles); }
|
||||
public String branchId() { return branchId; }
|
||||
public UserStatus status() { return status; }
|
||||
public LocalDateTime createdAt() { return createdAt; }
|
||||
public LocalDateTime lastLogin() { return lastLogin; }
|
||||
|
||||
@Override
|
||||
public boolean equals(Object obj) {
|
||||
if (this == obj) return true;
|
||||
if (!(obj instanceof User other)) return false;
|
||||
return id.equals(other.id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return id.hashCode();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "User{id=" + id + ", username='" + username + "', email='" + email + "', status=" + status + "}";
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
package de.effigenix.domain.usermanagement;
|
||||
|
||||
/**
|
||||
* Domain errors for User Management.
|
||||
* Sealed interface ensures exhaustive handling of all error types.
|
||||
*/
|
||||
public sealed interface UserError {
|
||||
|
||||
String code();
|
||||
String message();
|
||||
|
||||
record UsernameAlreadyExists(String username) implements UserError {
|
||||
@Override public String code() { return "USER_USERNAME_EXISTS"; }
|
||||
@Override public String message() { return "Username '" + username + "' already exists"; }
|
||||
}
|
||||
|
||||
record EmailAlreadyExists(String email) implements UserError {
|
||||
@Override public String code() { return "USER_EMAIL_EXISTS"; }
|
||||
@Override public String message() { return "Email '" + email + "' already exists"; }
|
||||
}
|
||||
|
||||
record UserNotFound(UserId userId) implements UserError {
|
||||
@Override public String code() { return "USER_NOT_FOUND"; }
|
||||
@Override public String message() { return "User with ID '" + userId.value() + "' not found"; }
|
||||
}
|
||||
|
||||
record InvalidCredentials() implements UserError {
|
||||
@Override public String code() { return "USER_INVALID_CREDENTIALS"; }
|
||||
@Override public String message() { return "Invalid username or password"; }
|
||||
}
|
||||
|
||||
record UserLocked(UserId userId) implements UserError {
|
||||
@Override public String code() { return "USER_LOCKED"; }
|
||||
@Override public String message() { return "User account is locked"; }
|
||||
}
|
||||
|
||||
record UserInactive(UserId userId) implements UserError {
|
||||
@Override public String code() { return "USER_INACTIVE"; }
|
||||
@Override public String message() { return "User account is inactive"; }
|
||||
}
|
||||
|
||||
record RoleNotFound(RoleName roleName) implements UserError {
|
||||
@Override public String code() { return "ROLE_NOT_FOUND"; }
|
||||
@Override public String message() { return "Role '" + roleName + "' not found"; }
|
||||
}
|
||||
|
||||
record InvalidPassword(String reason) implements UserError {
|
||||
@Override public String code() { return "USER_INVALID_PASSWORD"; }
|
||||
@Override public String message() { return "Invalid password: " + reason; }
|
||||
}
|
||||
|
||||
record Unauthorized(String message) implements UserError {
|
||||
@Override public String code() { return "UNAUTHORIZED"; }
|
||||
}
|
||||
|
||||
record InvalidEmail(String email) implements UserError {
|
||||
@Override public String code() { return "USER_INVALID_EMAIL"; }
|
||||
@Override public String message() { return "Invalid email format: " + email; }
|
||||
}
|
||||
|
||||
record InvalidUsername(String reason) implements UserError {
|
||||
@Override public String code() { return "USER_INVALID_USERNAME"; }
|
||||
@Override public String message() { return "Invalid username: " + reason; }
|
||||
}
|
||||
|
||||
record NullPasswordHash() implements UserError {
|
||||
@Override public String code() { return "USER_NULL_PASSWORD_HASH"; }
|
||||
@Override public String message() { return "PasswordHash cannot be null"; }
|
||||
}
|
||||
|
||||
record NullRole() implements UserError {
|
||||
@Override public String code() { return "USER_NULL_ROLE"; }
|
||||
@Override public String message() { return "Role cannot be null"; }
|
||||
}
|
||||
|
||||
record RepositoryFailure(String message) implements UserError {
|
||||
@Override public String code() { return "REPOSITORY_ERROR"; }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
package de.effigenix.domain.usermanagement;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Value Object representing a User's unique identifier.
|
||||
* Immutable and self-validating.
|
||||
*/
|
||||
public record UserId(String value) {
|
||||
|
||||
public UserId {
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new IllegalArgumentException("UserId cannot be null or empty");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a new random UserId.
|
||||
*/
|
||||
public static UserId generate() {
|
||||
return new UserId(UUID.randomUUID().toString());
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a UserId from a string value.
|
||||
*/
|
||||
public static UserId of(String value) {
|
||||
return new UserId(value);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
package de.effigenix.domain.usermanagement;
|
||||
|
||||
import de.effigenix.shared.common.Result;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Repository interface for User entity.
|
||||
* Domain Layer defines the contract, Infrastructure Layer implements it.
|
||||
*/
|
||||
public interface UserRepository {
|
||||
|
||||
Result<RepositoryError, Optional<User>> findById(UserId id);
|
||||
|
||||
Result<RepositoryError, Optional<User>> findByUsername(String username);
|
||||
|
||||
Result<RepositoryError, Optional<User>> findByEmail(String email);
|
||||
|
||||
Result<RepositoryError, List<User>> findByBranchId(String branchId);
|
||||
|
||||
Result<RepositoryError, List<User>> findByStatus(UserStatus status);
|
||||
|
||||
Result<RepositoryError, List<User>> findAll();
|
||||
|
||||
Result<RepositoryError, Void> save(User user);
|
||||
|
||||
Result<RepositoryError, Void> delete(User user);
|
||||
|
||||
Result<RepositoryError, Boolean> existsByUsername(String username);
|
||||
|
||||
Result<RepositoryError, Boolean> existsByEmail(String email);
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
package de.effigenix.domain.usermanagement;
|
||||
|
||||
/**
|
||||
* User account status.
|
||||
*/
|
||||
public enum UserStatus {
|
||||
/**
|
||||
* User is active and can log in.
|
||||
*/
|
||||
ACTIVE,
|
||||
|
||||
/**
|
||||
* User is inactive (e.g., on leave, not yet activated).
|
||||
*/
|
||||
INACTIVE,
|
||||
|
||||
/**
|
||||
* User is locked (e.g., too many failed login attempts, manually locked by admin).
|
||||
*/
|
||||
LOCKED
|
||||
}
|
||||
|
|
@ -0,0 +1,119 @@
|
|||
package de.effigenix.infrastructure.audit;
|
||||
|
||||
import de.effigenix.application.usermanagement.AuditEvent;
|
||||
import jakarta.persistence.*;
|
||||
import org.springframework.data.annotation.CreatedDate;
|
||||
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* JPA Entity for Audit Logs.
|
||||
*
|
||||
* HACCP/GoBD Compliance:
|
||||
* - Immutable after creation (no update allowed)
|
||||
* - Retention: 10 years (gesetzlich)
|
||||
* - Contains: Who (actor), What (event), When (timestamp), Where (IP), How (user agent)
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "audit_logs", indexes = {
|
||||
@Index(name = "idx_audit_event", columnList = "event"),
|
||||
@Index(name = "idx_audit_actor", columnList = "performed_by"),
|
||||
@Index(name = "idx_audit_timestamp", columnList = "timestamp"),
|
||||
@Index(name = "idx_audit_entity", columnList = "entity_id")
|
||||
})
|
||||
@EntityListeners(AuditingEntityListener.class)
|
||||
public class AuditLogEntity {
|
||||
|
||||
@Id
|
||||
@Column(name = "id", nullable = false, length = 36)
|
||||
private String id;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "event", nullable = false, length = 100)
|
||||
private AuditEvent event;
|
||||
|
||||
@Column(name = "entity_id", length = 36)
|
||||
private String entityId;
|
||||
|
||||
@Column(name = "performed_by", length = 36)
|
||||
private String performedBy; // ActorId
|
||||
|
||||
@Column(name = "details", length = 2000)
|
||||
private String details;
|
||||
|
||||
@Column(name = "timestamp", nullable = false)
|
||||
private LocalDateTime timestamp;
|
||||
|
||||
@Column(name = "ip_address", length = 45) // IPv6 max length
|
||||
private String ipAddress;
|
||||
|
||||
@Column(name = "user_agent", length = 500)
|
||||
private String userAgent;
|
||||
|
||||
@CreatedDate
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
// JPA requires no-arg constructor
|
||||
protected AuditLogEntity() {
|
||||
}
|
||||
|
||||
public AuditLogEntity(
|
||||
String id,
|
||||
AuditEvent event,
|
||||
String entityId,
|
||||
String performedBy,
|
||||
String details,
|
||||
LocalDateTime timestamp,
|
||||
String ipAddress,
|
||||
String userAgent
|
||||
) {
|
||||
this.id = id;
|
||||
this.event = event;
|
||||
this.entityId = entityId;
|
||||
this.performedBy = performedBy;
|
||||
this.details = details;
|
||||
this.timestamp = timestamp;
|
||||
this.ipAddress = ipAddress;
|
||||
this.userAgent = userAgent;
|
||||
this.createdAt = LocalDateTime.now();
|
||||
}
|
||||
|
||||
// Getters only (immutable after creation)
|
||||
public String getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public AuditEvent getEvent() {
|
||||
return event;
|
||||
}
|
||||
|
||||
public String getEntityId() {
|
||||
return entityId;
|
||||
}
|
||||
|
||||
public String getPerformedBy() {
|
||||
return performedBy;
|
||||
}
|
||||
|
||||
public String getDetails() {
|
||||
return details;
|
||||
}
|
||||
|
||||
public LocalDateTime getTimestamp() {
|
||||
return timestamp;
|
||||
}
|
||||
|
||||
public String getIpAddress() {
|
||||
return ipAddress;
|
||||
}
|
||||
|
||||
public String getUserAgent() {
|
||||
return userAgent;
|
||||
}
|
||||
|
||||
public LocalDateTime getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
package de.effigenix.infrastructure.audit;
|
||||
|
||||
import de.effigenix.application.usermanagement.AuditEvent;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Spring Data JPA Repository for AuditLogEntity.
|
||||
*
|
||||
* Read-only operations for querying audit logs.
|
||||
* No delete/update operations (HACCP/GoBD compliance - immutable logs).
|
||||
*/
|
||||
@Repository
|
||||
public interface AuditLogJpaRepository extends JpaRepository<AuditLogEntity, String> {
|
||||
|
||||
/**
|
||||
* Finds all audit logs for a specific event type.
|
||||
*/
|
||||
List<AuditLogEntity> findByEvent(AuditEvent event);
|
||||
|
||||
/**
|
||||
* Finds all audit logs performed by a specific actor.
|
||||
*/
|
||||
List<AuditLogEntity> findByPerformedBy(String performedBy);
|
||||
|
||||
/**
|
||||
* Finds all audit logs for a specific entity.
|
||||
*/
|
||||
List<AuditLogEntity> findByEntityId(String entityId);
|
||||
|
||||
/**
|
||||
* Finds all audit logs within a time range.
|
||||
*/
|
||||
List<AuditLogEntity> findByTimestampBetween(LocalDateTime start, LocalDateTime end);
|
||||
|
||||
/**
|
||||
* Finds all audit logs for a specific event and actor.
|
||||
*/
|
||||
List<AuditLogEntity> findByEventAndPerformedBy(AuditEvent event, String performedBy);
|
||||
}
|
||||
|
|
@ -0,0 +1,161 @@
|
|||
package de.effigenix.infrastructure.audit;
|
||||
|
||||
import de.effigenix.application.usermanagement.AuditEvent;
|
||||
import de.effigenix.application.usermanagement.AuditLogger;
|
||||
import de.effigenix.shared.security.ActorId;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Propagation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.context.request.RequestContextHolder;
|
||||
import org.springframework.web.context.request.ServletRequestAttributes;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Database-backed implementation of AuditLogger.
|
||||
*
|
||||
* HACCP/GoBD Compliance:
|
||||
* - All operations are async (@Async) for performance (don't block business logic)
|
||||
* - Logs are written to database for durability
|
||||
* - Logs are immutable after creation
|
||||
* - Includes IP address and user agent for forensics
|
||||
* - Retention: 10 years (gesetzlich)
|
||||
*
|
||||
* Architecture:
|
||||
* - Infrastructure Layer implementation
|
||||
* - Adapts Application Layer's AuditLogger port
|
||||
* - Uses REQUIRES_NEW transaction to ensure audit logs are committed even if business transaction fails
|
||||
*/
|
||||
@Service
|
||||
public class DatabaseAuditLogger implements AuditLogger {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(DatabaseAuditLogger.class);
|
||||
|
||||
private final AuditLogJpaRepository repository;
|
||||
|
||||
public DatabaseAuditLogger(AuditLogJpaRepository repository) {
|
||||
this.repository = repository;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Async
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
public void log(AuditEvent event, String entityId, ActorId performedBy) {
|
||||
try {
|
||||
AuditLogEntity auditLog = new AuditLogEntity(
|
||||
UUID.randomUUID().toString(),
|
||||
event,
|
||||
entityId,
|
||||
performedBy.value(),
|
||||
null, // no additional details
|
||||
LocalDateTime.now(),
|
||||
getClientIpAddress(),
|
||||
getUserAgent()
|
||||
);
|
||||
|
||||
repository.save(auditLog);
|
||||
log.debug("Audit log created: event={}, entityId={}, actor={}", event, entityId, performedBy.value());
|
||||
} catch (Exception e) {
|
||||
// Never fail business logic due to audit logging errors
|
||||
log.error("Failed to create audit log: event={}, entityId={}, actor={}", event, entityId, performedBy.value(), e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@Async
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
public void log(AuditEvent event, String details) {
|
||||
try {
|
||||
AuditLogEntity auditLog = new AuditLogEntity(
|
||||
UUID.randomUUID().toString(),
|
||||
event,
|
||||
null, // no entity ID
|
||||
null, // no actor (e.g., system event)
|
||||
details,
|
||||
LocalDateTime.now(),
|
||||
getClientIpAddress(),
|
||||
getUserAgent()
|
||||
);
|
||||
|
||||
repository.save(auditLog);
|
||||
log.debug("Audit log created: event={}, details={}", event, details);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to create audit log: event={}, details={}", event, details, e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@Async
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
public void log(AuditEvent event, ActorId performedBy) {
|
||||
try {
|
||||
AuditLogEntity auditLog = new AuditLogEntity(
|
||||
UUID.randomUUID().toString(),
|
||||
event,
|
||||
null, // no entity ID
|
||||
performedBy.value(),
|
||||
null, // no additional details
|
||||
LocalDateTime.now(),
|
||||
getClientIpAddress(),
|
||||
getUserAgent()
|
||||
);
|
||||
|
||||
repository.save(auditLog);
|
||||
log.debug("Audit log created: event={}, actor={}", event, performedBy.value());
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to create audit log: event={}, actor={}", event, performedBy.value(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts client IP address from HTTP request.
|
||||
* Handles X-Forwarded-For header for proxy/load balancer scenarios.
|
||||
*/
|
||||
private String getClientIpAddress() {
|
||||
try {
|
||||
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
|
||||
if (attributes == null) {
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
HttpServletRequest request = attributes.getRequest();
|
||||
|
||||
// Check X-Forwarded-For header (proxy/load balancer)
|
||||
String xForwardedFor = request.getHeader("X-Forwarded-For");
|
||||
if (xForwardedFor != null && !xForwardedFor.isEmpty()) {
|
||||
// X-Forwarded-For: client, proxy1, proxy2
|
||||
return xForwardedFor.split(",")[0].trim();
|
||||
}
|
||||
|
||||
// Fallback to remote address
|
||||
return request.getRemoteAddr();
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to extract client IP address", e);
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts User-Agent header from HTTP request.
|
||||
*/
|
||||
private String getUserAgent() {
|
||||
try {
|
||||
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
|
||||
if (attributes == null) {
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
HttpServletRequest request = attributes.getRequest();
|
||||
String userAgent = request.getHeader("User-Agent");
|
||||
return userAgent != null ? userAgent : "unknown";
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to extract User-Agent", e);
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
package de.effigenix.infrastructure.config;
|
||||
|
||||
import de.effigenix.application.usermanagement.*;
|
||||
import de.effigenix.domain.usermanagement.RoleRepository;
|
||||
import de.effigenix.domain.usermanagement.UserRepository;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* Spring configuration for use case beans.
|
||||
*
|
||||
* Use cases are no longer @Service-annotated; they are instantiated
|
||||
* explicitly here with their dependencies injected via constructor.
|
||||
*/
|
||||
@Configuration
|
||||
public class UseCaseConfiguration {
|
||||
|
||||
@Bean
|
||||
public CreateUser createUser(
|
||||
UserRepository userRepository,
|
||||
RoleRepository roleRepository,
|
||||
PasswordHasher passwordHasher,
|
||||
AuditLogger auditLogger
|
||||
) {
|
||||
return new CreateUser(userRepository, roleRepository, passwordHasher, auditLogger);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public AuthenticateUser authenticateUser(
|
||||
UserRepository userRepository,
|
||||
PasswordHasher passwordHasher,
|
||||
SessionManager sessionManager,
|
||||
AuditLogger auditLogger
|
||||
) {
|
||||
return new AuthenticateUser(userRepository, passwordHasher, sessionManager, auditLogger);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public ChangePassword changePassword(
|
||||
UserRepository userRepository,
|
||||
PasswordHasher passwordHasher,
|
||||
AuditLogger auditLogger
|
||||
) {
|
||||
return new ChangePassword(userRepository, passwordHasher, auditLogger);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public UpdateUser updateUser(
|
||||
UserRepository userRepository,
|
||||
AuditLogger auditLogger
|
||||
) {
|
||||
return new UpdateUser(userRepository, auditLogger);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public AssignRole assignRole(
|
||||
UserRepository userRepository,
|
||||
RoleRepository roleRepository,
|
||||
AuditLogger auditLogger
|
||||
) {
|
||||
return new AssignRole(userRepository, roleRepository, auditLogger);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public RemoveRole removeRole(
|
||||
UserRepository userRepository,
|
||||
RoleRepository roleRepository,
|
||||
AuditLogger auditLogger
|
||||
) {
|
||||
return new RemoveRole(userRepository, roleRepository, auditLogger);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public LockUser lockUser(
|
||||
UserRepository userRepository,
|
||||
AuditLogger auditLogger
|
||||
) {
|
||||
return new LockUser(userRepository, auditLogger);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public UnlockUser unlockUser(
|
||||
UserRepository userRepository,
|
||||
AuditLogger auditLogger
|
||||
) {
|
||||
return new UnlockUser(userRepository, auditLogger);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public GetUser getUser(UserRepository userRepository) {
|
||||
return new GetUser(userRepository);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public ListUsers listUsers(UserRepository userRepository) {
|
||||
return new ListUsers(userRepository);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,160 @@
|
|||
package de.effigenix.infrastructure.security;
|
||||
|
||||
import de.effigenix.domain.filiales.FilialesAction;
|
||||
import de.effigenix.domain.inventory.InventoryAction;
|
||||
import de.effigenix.domain.labeling.LabelingAction;
|
||||
import de.effigenix.domain.procurement.ProcurementAction;
|
||||
import de.effigenix.domain.production.ProductionAction;
|
||||
import de.effigenix.domain.quality.QualityAction;
|
||||
import de.effigenix.domain.sales.SalesAction;
|
||||
import de.effigenix.domain.usermanagement.Permission;
|
||||
import de.effigenix.shared.security.Action;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Type-safe mapper from Bounded Context Actions to User Management Permissions.
|
||||
*
|
||||
* This is the Anti-Corruption Layer (ACL) between:
|
||||
* - Domain Language: BCs use their own Action enums (e.g., ProductionAction.RECIPE_WRITE)
|
||||
* - User Management: Uses granular Permissions (e.g., Permission.RECIPE_WRITE)
|
||||
*
|
||||
* Benefits:
|
||||
* - Type Safety: Compile-time checking for known action types
|
||||
* - Decoupling: BCs don't depend on Permission enum
|
||||
* - Future-Proof: Adding new BC actions requires updating this mapper
|
||||
* - Keycloak Migration: Can replace this with KeycloakActionMapper without BC changes
|
||||
*
|
||||
* Note: Cannot use sealed interfaces because Java requires all permitted classes
|
||||
* to be in the same package (unless using JPMS modules). Instead, we use instanceof
|
||||
* checks with enum switching for type safety.
|
||||
*
|
||||
* Example:
|
||||
* <pre>
|
||||
* Action action = ProductionAction.RECIPE_WRITE;
|
||||
* Permission permission = mapper.mapActionToPermission(action);
|
||||
* // permission == Permission.RECIPE_WRITE
|
||||
* </pre>
|
||||
*
|
||||
* Infrastructure Layer → Used by SpringSecurityAuthorizationAdapter
|
||||
*/
|
||||
@Component
|
||||
public class ActionToPermissionMapper {
|
||||
|
||||
/**
|
||||
* Maps a domain Action to a User Management Permission.
|
||||
*
|
||||
* Uses instanceof checks to determine the BC, then switches on the enum value.
|
||||
* While not compiler-enforced exhaustive like sealed interfaces, the enum switches
|
||||
* within each instanceof block provide compile-time safety for known enum values.
|
||||
*
|
||||
* @param action Type-safe action from a BC's Action enum
|
||||
* @return Corresponding Permission
|
||||
* @throws IllegalArgumentException if action is null or unknown type
|
||||
*/
|
||||
public Permission mapActionToPermission(Action action) {
|
||||
if (action == null) {
|
||||
throw new IllegalArgumentException("Action cannot be null");
|
||||
}
|
||||
|
||||
// Pattern matching with instanceof checks
|
||||
if (action instanceof ProductionAction productionAction) {
|
||||
return mapProductionAction(productionAction);
|
||||
} else if (action instanceof QualityAction qualityAction) {
|
||||
return mapQualityAction(qualityAction);
|
||||
} else if (action instanceof InventoryAction inventoryAction) {
|
||||
return mapInventoryAction(inventoryAction);
|
||||
} else if (action instanceof ProcurementAction procurementAction) {
|
||||
return mapProcurementAction(procurementAction);
|
||||
} else if (action instanceof SalesAction salesAction) {
|
||||
return mapSalesAction(salesAction);
|
||||
} else if (action instanceof LabelingAction labelingAction) {
|
||||
return mapLabelingAction(labelingAction);
|
||||
} else if (action instanceof FilialesAction filialesAction) {
|
||||
return mapFilialesAction(filialesAction);
|
||||
} else {
|
||||
throw new IllegalArgumentException("Unknown action type: " + action.getClass().getName());
|
||||
}
|
||||
}
|
||||
|
||||
private Permission mapProductionAction(ProductionAction action) {
|
||||
return switch (action) {
|
||||
case RECIPE_READ -> Permission.RECIPE_READ;
|
||||
case RECIPE_WRITE -> Permission.RECIPE_WRITE;
|
||||
case RECIPE_DELETE -> Permission.RECIPE_DELETE;
|
||||
case BATCH_READ -> Permission.BATCH_READ;
|
||||
case BATCH_WRITE -> Permission.BATCH_WRITE;
|
||||
case BATCH_COMPLETE -> Permission.BATCH_COMPLETE;
|
||||
case BATCH_DELETE -> Permission.BATCH_DELETE;
|
||||
case PRODUCTION_ORDER_READ -> Permission.PRODUCTION_ORDER_READ;
|
||||
case PRODUCTION_ORDER_WRITE -> Permission.PRODUCTION_ORDER_WRITE;
|
||||
case PRODUCTION_ORDER_DELETE -> Permission.PRODUCTION_ORDER_DELETE;
|
||||
};
|
||||
}
|
||||
|
||||
private Permission mapQualityAction(QualityAction action) {
|
||||
return switch (action) {
|
||||
case HACCP_READ -> Permission.HACCP_READ;
|
||||
case HACCP_WRITE -> Permission.HACCP_WRITE;
|
||||
case TEMPERATURE_LOG_READ -> Permission.TEMPERATURE_LOG_READ;
|
||||
case TEMPERATURE_LOG_WRITE -> Permission.TEMPERATURE_LOG_WRITE;
|
||||
case CLEANING_RECORD_READ -> Permission.CLEANING_RECORD_READ;
|
||||
case CLEANING_RECORD_WRITE -> Permission.CLEANING_RECORD_WRITE;
|
||||
case GOODS_INSPECTION_READ -> Permission.GOODS_INSPECTION_READ;
|
||||
case GOODS_INSPECTION_WRITE -> Permission.GOODS_INSPECTION_WRITE;
|
||||
};
|
||||
}
|
||||
|
||||
private Permission mapInventoryAction(InventoryAction action) {
|
||||
return switch (action) {
|
||||
case STOCK_READ -> Permission.STOCK_READ;
|
||||
case STOCK_WRITE -> Permission.STOCK_WRITE;
|
||||
case STOCK_MOVEMENT_READ -> Permission.STOCK_MOVEMENT_READ;
|
||||
case STOCK_MOVEMENT_WRITE -> Permission.STOCK_MOVEMENT_WRITE;
|
||||
case INVENTORY_COUNT_READ -> Permission.INVENTORY_COUNT_READ;
|
||||
case INVENTORY_COUNT_WRITE -> Permission.INVENTORY_COUNT_WRITE;
|
||||
};
|
||||
}
|
||||
|
||||
private Permission mapProcurementAction(ProcurementAction action) {
|
||||
return switch (action) {
|
||||
case PURCHASE_ORDER_READ -> Permission.PURCHASE_ORDER_READ;
|
||||
case PURCHASE_ORDER_WRITE -> Permission.PURCHASE_ORDER_WRITE;
|
||||
case PURCHASE_ORDER_DELETE -> Permission.PURCHASE_ORDER_DELETE;
|
||||
case GOODS_RECEIPT_READ -> Permission.GOODS_RECEIPT_READ;
|
||||
case GOODS_RECEIPT_WRITE -> Permission.GOODS_RECEIPT_WRITE;
|
||||
case SUPPLIER_READ -> Permission.SUPPLIER_READ;
|
||||
case SUPPLIER_WRITE -> Permission.SUPPLIER_WRITE;
|
||||
case SUPPLIER_DELETE -> Permission.SUPPLIER_DELETE;
|
||||
};
|
||||
}
|
||||
|
||||
private Permission mapSalesAction(SalesAction action) {
|
||||
return switch (action) {
|
||||
case ORDER_READ -> Permission.ORDER_READ;
|
||||
case ORDER_WRITE -> Permission.ORDER_WRITE;
|
||||
case ORDER_DELETE -> Permission.ORDER_DELETE;
|
||||
case INVOICE_READ -> Permission.INVOICE_READ;
|
||||
case INVOICE_WRITE -> Permission.INVOICE_WRITE;
|
||||
case INVOICE_DELETE -> Permission.INVOICE_DELETE;
|
||||
case CUSTOMER_READ -> Permission.CUSTOMER_READ;
|
||||
case CUSTOMER_WRITE -> Permission.CUSTOMER_WRITE;
|
||||
case CUSTOMER_DELETE -> Permission.CUSTOMER_DELETE;
|
||||
};
|
||||
}
|
||||
|
||||
private Permission mapLabelingAction(LabelingAction action) {
|
||||
return switch (action) {
|
||||
case LABEL_READ -> Permission.LABEL_READ;
|
||||
case LABEL_WRITE -> Permission.LABEL_WRITE;
|
||||
case LABEL_PRINT -> Permission.LABEL_PRINT;
|
||||
};
|
||||
}
|
||||
|
||||
private Permission mapFilialesAction(FilialesAction action) {
|
||||
return switch (action) {
|
||||
case BRANCH_READ -> Permission.BRANCH_READ;
|
||||
case BRANCH_WRITE -> Permission.BRANCH_WRITE;
|
||||
case BRANCH_DELETE -> Permission.BRANCH_DELETE;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
package de.effigenix.infrastructure.security;
|
||||
|
||||
import de.effigenix.application.usermanagement.PasswordHasher;
|
||||
import de.effigenix.domain.usermanagement.PasswordHash;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* BCrypt implementation of PasswordHasher interface.
|
||||
* Uses BCrypt with strength 12 for password hashing.
|
||||
*
|
||||
* BCrypt is a password hashing function designed by Niels Provos and David Mazières,
|
||||
* based on the Blowfish cipher. It includes a salt to protect against rainbow table attacks
|
||||
* and an adaptive function: over time, the iteration count can be increased to make it slower,
|
||||
* protecting against brute-force search attacks even with increasing computation power.
|
||||
*
|
||||
* Strength 12 provides a good balance between security and performance:
|
||||
* - ~250ms on modern hardware (acceptable for login)
|
||||
* - 2^12 = 4,096 iterations
|
||||
* - Resistant to brute-force attacks
|
||||
*
|
||||
* Infrastructure Layer → Implements Application Layer port
|
||||
*/
|
||||
@Component
|
||||
public class BCryptPasswordHasher implements PasswordHasher {
|
||||
|
||||
private static final int BCRYPT_STRENGTH = 12;
|
||||
private static final int MIN_PASSWORD_LENGTH = 8;
|
||||
|
||||
private final BCryptPasswordEncoder encoder;
|
||||
|
||||
public BCryptPasswordHasher() {
|
||||
this.encoder = new BCryptPasswordEncoder(BCRYPT_STRENGTH);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hashes a plain-text password using BCrypt with strength 12.
|
||||
*
|
||||
* @param plainPassword Plain-text password (never stored!)
|
||||
* @return BCrypt hash (60 characters, starts with $2a$, $2b$, or $2y$)
|
||||
* @throws IllegalArgumentException if password is invalid
|
||||
*/
|
||||
@Override
|
||||
public PasswordHash hash(String plainPassword) {
|
||||
if (plainPassword == null || plainPassword.isBlank()) {
|
||||
throw new IllegalArgumentException("Password cannot be null or empty");
|
||||
}
|
||||
if (!isValidPassword(plainPassword)) {
|
||||
throw new IllegalArgumentException("Password does not meet minimum requirements");
|
||||
}
|
||||
|
||||
String bcryptHash = encoder.encode(plainPassword);
|
||||
return PasswordHash.of(bcryptHash);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies a plain-text password against a BCrypt hash.
|
||||
* Uses constant-time comparison to prevent timing attacks.
|
||||
*
|
||||
* @param plainPassword Plain-text password to verify
|
||||
* @param passwordHash BCrypt hash to compare against
|
||||
* @return true if password matches, false otherwise
|
||||
*/
|
||||
@Override
|
||||
public boolean verify(String plainPassword, PasswordHash passwordHash) {
|
||||
if (plainPassword == null || passwordHash == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return encoder.matches(plainPassword, passwordHash.value());
|
||||
} catch (Exception e) {
|
||||
// Invalid hash format or other error - return false instead of throwing
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates password strength (minimum requirements).
|
||||
*
|
||||
* Requirements:
|
||||
* - Minimum 8 characters
|
||||
* - At least one uppercase letter
|
||||
* - At least one lowercase letter
|
||||
* - At least one digit
|
||||
* - At least one special character
|
||||
*
|
||||
* @param plainPassword Plain-text password to validate
|
||||
* @return true if password meets requirements, false otherwise
|
||||
*/
|
||||
@Override
|
||||
public boolean isValidPassword(String plainPassword) {
|
||||
if (plainPassword == null || plainPassword.length() < MIN_PASSWORD_LENGTH) {
|
||||
return false;
|
||||
}
|
||||
|
||||
boolean hasUpperCase = false;
|
||||
boolean hasLowerCase = false;
|
||||
boolean hasDigit = false;
|
||||
boolean hasSpecialChar = false;
|
||||
|
||||
for (char c : plainPassword.toCharArray()) {
|
||||
if (Character.isUpperCase(c)) {
|
||||
hasUpperCase = true;
|
||||
} else if (Character.isLowerCase(c)) {
|
||||
hasLowerCase = true;
|
||||
} else if (Character.isDigit(c)) {
|
||||
hasDigit = true;
|
||||
} else if (!Character.isWhitespace(c)) {
|
||||
hasSpecialChar = true;
|
||||
}
|
||||
}
|
||||
|
||||
return hasUpperCase && hasLowerCase && hasDigit && hasSpecialChar;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
package de.effigenix.infrastructure.security;
|
||||
|
||||
/**
|
||||
* Authentication details for JWT-based authentication.
|
||||
*
|
||||
* Stores additional information extracted from JWT token:
|
||||
* - Username: User's username
|
||||
* - BranchId: User's assigned branch (for multi-branch filtering)
|
||||
*
|
||||
* This is stored in the Authentication.details field and can be accessed
|
||||
* by the SpringSecurityAuthorizationAdapter for resource-level authorization.
|
||||
*
|
||||
* Infrastructure Layer → Used by JwtAuthenticationFilter
|
||||
*/
|
||||
public class JwtAuthenticationDetails {
|
||||
|
||||
private final String username;
|
||||
private final String branchId;
|
||||
|
||||
public JwtAuthenticationDetails(String username, String branchId) {
|
||||
this.username = username;
|
||||
this.branchId = branchId;
|
||||
}
|
||||
|
||||
public String getUsername() {
|
||||
return username;
|
||||
}
|
||||
|
||||
public String getBranchId() {
|
||||
return branchId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "JwtAuthenticationDetails{username='" + username + "', branchId='" + branchId + "'}";
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,134 @@
|
|||
package de.effigenix.infrastructure.security;
|
||||
|
||||
import de.effigenix.domain.usermanagement.Permission;
|
||||
import io.jsonwebtoken.JwtException;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.lang.NonNull;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* JWT Authentication Filter for Spring Security.
|
||||
*
|
||||
* Intercepts HTTP requests and validates JWT tokens from the Authorization header.
|
||||
* If valid, sets the Authentication in Spring SecurityContext.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Extract JWT token from Authorization header (Bearer <token>)
|
||||
* 2. Validate token signature and expiration
|
||||
* 3. Extract userId, username, permissions, branchId from token claims
|
||||
* 4. Create Authentication object with permissions as GrantedAuthorities
|
||||
* 5. Set Authentication in SecurityContext
|
||||
*
|
||||
* This filter runs BEFORE Spring Security's authentication filters.
|
||||
* It's registered in SecurityConfig via addFilterBefore().
|
||||
*
|
||||
* Infrastructure Layer → Spring Security Filter Chain
|
||||
*/
|
||||
@Component
|
||||
public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
|
||||
private static final String AUTHORIZATION_HEADER = "Authorization";
|
||||
private static final String BEARER_PREFIX = "Bearer ";
|
||||
|
||||
private final JwtTokenProvider tokenProvider;
|
||||
private final JwtSessionManager sessionManager;
|
||||
|
||||
public JwtAuthenticationFilter(JwtTokenProvider tokenProvider, JwtSessionManager sessionManager) {
|
||||
this.tokenProvider = tokenProvider;
|
||||
this.sessionManager = sessionManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters every HTTP request to validate JWT tokens.
|
||||
*
|
||||
* @param request HTTP request
|
||||
* @param response HTTP response
|
||||
* @param filterChain Filter chain to continue processing
|
||||
*/
|
||||
@Override
|
||||
protected void doFilterInternal(
|
||||
@NonNull HttpServletRequest request,
|
||||
@NonNull HttpServletResponse response,
|
||||
@NonNull FilterChain filterChain
|
||||
) throws ServletException, IOException {
|
||||
|
||||
try {
|
||||
// Extract JWT token from Authorization header
|
||||
String token = extractTokenFromRequest(request);
|
||||
|
||||
if (token != null) {
|
||||
// Validate token (signature, expiration, blacklist)
|
||||
sessionManager.validateToken(token);
|
||||
|
||||
// Extract user information from token
|
||||
String userId = tokenProvider.extractUserId(token).value();
|
||||
String username = tokenProvider.extractUsername(token);
|
||||
Set<Permission> permissions = tokenProvider.extractPermissions(token);
|
||||
String branchId = tokenProvider.extractBranchId(token);
|
||||
|
||||
// Convert permissions to Spring Security GrantedAuthorities
|
||||
var authorities = permissions.stream()
|
||||
.map(p -> new SimpleGrantedAuthority(p.name()))
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
// Create Authentication object
|
||||
// Principal: userId (used by AuthorizationPort.currentActor())
|
||||
// Credentials: null (stateless - no password needed)
|
||||
// Authorities: user's permissions
|
||||
var authentication = new UsernamePasswordAuthenticationToken(
|
||||
userId,
|
||||
null,
|
||||
authorities
|
||||
);
|
||||
|
||||
// Store additional details (username, branchId) for resource-level authorization
|
||||
var details = new JwtAuthenticationDetails(username, branchId);
|
||||
authentication.setDetails(details);
|
||||
|
||||
// Set authentication in SecurityContext
|
||||
SecurityContextHolder.getContext().setAuthentication(authentication);
|
||||
}
|
||||
|
||||
} catch (JwtException | SecurityException e) {
|
||||
// Token validation failed - clear SecurityContext and continue
|
||||
// Spring Security will return 401 Unauthorized for protected endpoints
|
||||
SecurityContextHolder.clearContext();
|
||||
|
||||
// Log the error for debugging
|
||||
logger.debug("JWT authentication failed: " + e.getMessage(), e);
|
||||
}
|
||||
|
||||
// Continue filter chain (even if authentication failed)
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts JWT token from the Authorization header.
|
||||
*
|
||||
* Expected format: "Authorization: Bearer <token>"
|
||||
*
|
||||
* @param request HTTP request
|
||||
* @return JWT token or null if not present
|
||||
*/
|
||||
private String extractTokenFromRequest(HttpServletRequest request) {
|
||||
String authHeader = request.getHeader(AUTHORIZATION_HEADER);
|
||||
|
||||
if (authHeader != null && authHeader.startsWith(BEARER_PREFIX)) {
|
||||
return authHeader.substring(BEARER_PREFIX.length());
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,189 @@
|
|||
package de.effigenix.infrastructure.security;
|
||||
|
||||
import de.effigenix.application.usermanagement.SessionManager;
|
||||
import de.effigenix.application.usermanagement.dto.SessionToken;
|
||||
import de.effigenix.domain.usermanagement.User;
|
||||
import de.effigenix.domain.usermanagement.UserId;
|
||||
import io.jsonwebtoken.JwtException;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* JWT-based Session Manager implementation.
|
||||
*
|
||||
* Implements the SessionManager port from Application Layer using JWT tokens.
|
||||
* Stateless session management - no server-side session storage.
|
||||
*
|
||||
* Token Invalidation:
|
||||
* - Access tokens are stateless (cannot be truly invalidated)
|
||||
* - For MVP: Use in-memory blacklist for logged-out tokens
|
||||
* - For Production: Consider Redis-based blacklist or short token expiration
|
||||
*
|
||||
* Refresh Tokens:
|
||||
* - For MVP: Simple refresh token validation
|
||||
* - For Production: Consider refresh token rotation and family tracking
|
||||
*
|
||||
* Infrastructure Layer → Implements Application Layer port
|
||||
*/
|
||||
@Service
|
||||
public class JwtSessionManager implements SessionManager {
|
||||
|
||||
private final JwtTokenProvider tokenProvider;
|
||||
|
||||
// In-memory token blacklist (for MVP - replace with Redis in production)
|
||||
private final Set<String> tokenBlacklist = ConcurrentHashMap.newKeySet();
|
||||
|
||||
public JwtSessionManager(JwtTokenProvider tokenProvider) {
|
||||
this.tokenProvider = tokenProvider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new session (JWT token) for a user.
|
||||
*
|
||||
* Generates both access and refresh tokens:
|
||||
* - Access token: Short-lived (8h), contains user info and permissions
|
||||
* - Refresh token: Long-lived (7d), used to obtain new access tokens
|
||||
*
|
||||
* @param user User to create session for
|
||||
* @return Session token (access token + refresh token)
|
||||
*/
|
||||
@Override
|
||||
public SessionToken createSession(User user) {
|
||||
if (user == null) {
|
||||
throw new IllegalArgumentException("User cannot be null");
|
||||
}
|
||||
|
||||
// Generate access token with user information and permissions
|
||||
String accessToken = tokenProvider.generateAccessToken(
|
||||
user.id(),
|
||||
user.username(),
|
||||
user.getAllPermissions(),
|
||||
user.branchId()
|
||||
);
|
||||
|
||||
// Generate refresh token for session renewal
|
||||
String refreshToken = tokenProvider.generateRefreshToken(user.id());
|
||||
|
||||
return SessionToken.create(
|
||||
accessToken,
|
||||
tokenProvider.getAccessTokenExpiration(),
|
||||
refreshToken
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a JWT token and extracts the user ID.
|
||||
*
|
||||
* Checks:
|
||||
* 1. Token signature is valid
|
||||
* 2. Token is not expired
|
||||
* 3. Token is not blacklisted (logged out)
|
||||
*
|
||||
* @param token JWT access token
|
||||
* @return UserId if valid
|
||||
* @throws RuntimeException if token is invalid, expired, or blacklisted
|
||||
*/
|
||||
@Override
|
||||
public UserId validateToken(String token) {
|
||||
if (token == null || token.isBlank()) {
|
||||
throw new IllegalArgumentException("Token cannot be null or empty");
|
||||
}
|
||||
|
||||
// Check if token is blacklisted (user logged out)
|
||||
if (tokenBlacklist.contains(token)) {
|
||||
throw new SecurityException("Token has been invalidated (user logged out)");
|
||||
}
|
||||
|
||||
try {
|
||||
// Validate token and extract userId
|
||||
return tokenProvider.extractUserId(token);
|
||||
} catch (JwtException e) {
|
||||
throw new SecurityException("Invalid or expired JWT token: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes an expired access token using a refresh token.
|
||||
*
|
||||
* Process:
|
||||
* 1. Validate refresh token
|
||||
* 2. Extract userId from refresh token
|
||||
* 3. Load user from repository (not implemented here - done in Application Layer)
|
||||
* 4. Generate new access token
|
||||
*
|
||||
* Note: This method only validates the refresh token and extracts userId.
|
||||
* The Application Layer is responsible for loading the user and creating a new session.
|
||||
*
|
||||
* @param refreshToken Refresh token
|
||||
* @return New session token
|
||||
* @throws RuntimeException if refresh token is invalid or expired
|
||||
*/
|
||||
@Override
|
||||
public SessionToken refreshSession(String refreshToken) {
|
||||
if (refreshToken == null || refreshToken.isBlank()) {
|
||||
throw new IllegalArgumentException("Refresh token cannot be null or empty");
|
||||
}
|
||||
|
||||
try {
|
||||
// Validate refresh token and extract userId
|
||||
UserId userId = tokenProvider.extractUserId(refreshToken);
|
||||
|
||||
// NOTE: In a real implementation, we would:
|
||||
// 1. Load the user from repository
|
||||
// 2. Verify user is still active
|
||||
// 3. Generate new access + refresh tokens
|
||||
//
|
||||
// For now, this is a placeholder that demonstrates the contract.
|
||||
// The Application Layer service will handle the full flow.
|
||||
|
||||
throw new UnsupportedOperationException(
|
||||
"Session refresh requires user loading from repository. " +
|
||||
"This should be implemented in the Application Layer service."
|
||||
);
|
||||
|
||||
} catch (JwtException e) {
|
||||
throw new SecurityException("Invalid or expired refresh token: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidates a session (logout).
|
||||
*
|
||||
* Adds the token to the blacklist to prevent further use.
|
||||
* Note: Blacklist is in-memory for MVP. For production, use Redis with TTL.
|
||||
*
|
||||
* @param token JWT access token to invalidate
|
||||
*/
|
||||
@Override
|
||||
public void invalidateSession(String token) {
|
||||
if (token != null && !token.isBlank()) {
|
||||
tokenBlacklist.add(token);
|
||||
|
||||
// TODO: In production, implement automatic cleanup of expired tokens from blacklist
|
||||
// Option 1: Use Redis with TTL (token expiration time)
|
||||
// Option 2: Background task to clean up expired tokens
|
||||
// Option 3: Use a time-based eviction cache (Caffeine, Guava)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a token is blacklisted.
|
||||
* Useful for debugging and testing.
|
||||
*
|
||||
* @param token JWT access token
|
||||
* @return true if token is blacklisted, false otherwise
|
||||
*/
|
||||
public boolean isTokenBlacklisted(String token) {
|
||||
return tokenBlacklist.contains(token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the token blacklist.
|
||||
* Useful for testing. DO NOT use in production!
|
||||
*/
|
||||
public void clearBlacklist() {
|
||||
tokenBlacklist.clear();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,205 @@
|
|||
package de.effigenix.infrastructure.security;
|
||||
|
||||
import de.effigenix.domain.usermanagement.Permission;
|
||||
import de.effigenix.domain.usermanagement.UserId;
|
||||
import io.jsonwebtoken.Claims;
|
||||
import io.jsonwebtoken.Jwts;
|
||||
import io.jsonwebtoken.security.Keys;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.crypto.SecretKey;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
import java.util.Date;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* JWT Token Provider using JJWT 0.12.5 library.
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Generate JWT access tokens with user information and permissions
|
||||
* - Generate JWT refresh tokens for session renewal
|
||||
* - Validate tokens (signature, expiration)
|
||||
* - Extract claims from tokens
|
||||
*
|
||||
* JWT Structure:
|
||||
* - Header: Algorithm (HS256) and token type
|
||||
* - Payload: userId, username, permissions, branchId, issued-at, expiration
|
||||
* - Signature: HMAC-SHA256 with secret key
|
||||
*
|
||||
* Infrastructure Layer → Used by JwtSessionManager
|
||||
*/
|
||||
@Component
|
||||
public class JwtTokenProvider {
|
||||
|
||||
private final SecretKey secretKey;
|
||||
private final long accessTokenExpiration;
|
||||
private final long refreshTokenExpiration;
|
||||
|
||||
/**
|
||||
* Constructor with configuration from application.yml.
|
||||
*
|
||||
* @param secret JWT secret key (minimum 256 bits for HS256)
|
||||
* @param accessTokenExpiration Access token expiration in milliseconds
|
||||
* @param refreshTokenExpiration Refresh token expiration in milliseconds
|
||||
*/
|
||||
public JwtTokenProvider(
|
||||
@Value("${jwt.secret}") String secret,
|
||||
@Value("${jwt.expiration}") long accessTokenExpiration,
|
||||
@Value("${jwt.refresh-expiration}") long refreshTokenExpiration
|
||||
) {
|
||||
this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
|
||||
this.accessTokenExpiration = accessTokenExpiration;
|
||||
this.refreshTokenExpiration = refreshTokenExpiration;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a JWT access token for a user.
|
||||
*
|
||||
* Claims:
|
||||
* - sub (subject): userId
|
||||
* - username: username
|
||||
* - permissions: comma-separated list of permissions
|
||||
* - branchId: user's assigned branch (optional)
|
||||
* - iat (issued-at): token creation timestamp
|
||||
* - exp (expiration): token expiration timestamp
|
||||
*
|
||||
* @param userId User's unique identifier
|
||||
* @param username User's username
|
||||
* @param permissions User's permissions from all assigned roles
|
||||
* @param branchId User's assigned branch (nullable)
|
||||
* @return JWT access token
|
||||
*/
|
||||
public String generateAccessToken(UserId userId, String username, Set<Permission> permissions, String branchId) {
|
||||
Instant now = Instant.now();
|
||||
Instant expiration = now.plusMillis(accessTokenExpiration);
|
||||
|
||||
// Convert permissions to comma-separated string
|
||||
String permissionsString = permissions.stream()
|
||||
.map(Permission::name)
|
||||
.collect(Collectors.joining(","));
|
||||
|
||||
var builder = Jwts.builder()
|
||||
.subject(userId.value())
|
||||
.claim("username", username)
|
||||
.claim("permissions", permissionsString)
|
||||
.issuedAt(Date.from(now))
|
||||
.expiration(Date.from(expiration))
|
||||
.signWith(secretKey);
|
||||
|
||||
// Add branchId only if present
|
||||
if (branchId != null && !branchId.isBlank()) {
|
||||
builder.claim("branchId", branchId);
|
||||
}
|
||||
|
||||
return builder.compact();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a JWT refresh token for session renewal.
|
||||
* Refresh tokens contain minimal information (only userId).
|
||||
*
|
||||
* @param userId User's unique identifier
|
||||
* @return JWT refresh token
|
||||
*/
|
||||
public String generateRefreshToken(UserId userId) {
|
||||
Instant now = Instant.now();
|
||||
Instant expiration = now.plusMillis(refreshTokenExpiration);
|
||||
|
||||
return Jwts.builder()
|
||||
.subject(userId.value())
|
||||
.claim("type", "refresh")
|
||||
.issuedAt(Date.from(now))
|
||||
.expiration(Date.from(expiration))
|
||||
.signWith(secretKey)
|
||||
.compact();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a JWT token and returns its claims.
|
||||
*
|
||||
* @param token JWT token to validate
|
||||
* @return Claims if valid
|
||||
* @throws io.jsonwebtoken.JwtException if token is invalid or expired
|
||||
*/
|
||||
public Claims validateToken(String token) {
|
||||
return Jwts.parser()
|
||||
.verifyWith(secretKey)
|
||||
.build()
|
||||
.parseSignedClaims(token)
|
||||
.getPayload();
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the userId from a validated token.
|
||||
*
|
||||
* @param token JWT token
|
||||
* @return UserId
|
||||
* @throws io.jsonwebtoken.JwtException if token is invalid
|
||||
*/
|
||||
public UserId extractUserId(String token) {
|
||||
Claims claims = validateToken(token);
|
||||
return UserId.of(claims.getSubject());
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the username from a validated token.
|
||||
*
|
||||
* @param token JWT token
|
||||
* @return username
|
||||
* @throws io.jsonwebtoken.JwtException if token is invalid
|
||||
*/
|
||||
public String extractUsername(String token) {
|
||||
Claims claims = validateToken(token);
|
||||
return claims.get("username", String.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the permissions from a validated token.
|
||||
*
|
||||
* @param token JWT token
|
||||
* @return Set of permissions
|
||||
* @throws io.jsonwebtoken.JwtException if token is invalid
|
||||
*/
|
||||
public Set<Permission> extractPermissions(String token) {
|
||||
Claims claims = validateToken(token);
|
||||
String permissionsString = claims.get("permissions", String.class);
|
||||
|
||||
if (permissionsString == null || permissionsString.isBlank()) {
|
||||
return Set.of();
|
||||
}
|
||||
|
||||
return Set.of(permissionsString.split(","))
|
||||
.stream()
|
||||
.map(Permission::valueOf)
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the branchId from a validated token (if present).
|
||||
*
|
||||
* @param token JWT token
|
||||
* @return BranchId or null if not present
|
||||
* @throws io.jsonwebtoken.JwtException if token is invalid
|
||||
*/
|
||||
public String extractBranchId(String token) {
|
||||
Claims claims = validateToken(token);
|
||||
return claims.get("branchId", String.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the access token expiration time in milliseconds.
|
||||
*/
|
||||
public long getAccessTokenExpiration() {
|
||||
return accessTokenExpiration;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the refresh token expiration time in milliseconds.
|
||||
*/
|
||||
public long getRefreshTokenExpiration() {
|
||||
return refreshTokenExpiration;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,142 @@
|
|||
package de.effigenix.infrastructure.security;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
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;
|
||||
|
||||
/**
|
||||
* Spring Security 6 Configuration for JWT-based authentication.
|
||||
*
|
||||
* Security Model:
|
||||
* - Stateless: No server-side sessions (SessionCreationPolicy.STATELESS)
|
||||
* - JWT-based: Authentication via JWT tokens in Authorization header
|
||||
* - BCrypt: Password hashing with strength 12
|
||||
* - Role-based: Authorization via permissions (not roles!)
|
||||
*
|
||||
* Endpoint Security:
|
||||
* - Public: /api/auth/login, /api/auth/refresh
|
||||
* - Protected: All other /api/** endpoints require authentication
|
||||
* - Swagger: /swagger-ui/**, /api-docs/** (public for development, restrict in production)
|
||||
*
|
||||
* Filter Chain:
|
||||
* 1. JwtAuthenticationFilter: Validates JWT and sets Authentication in SecurityContext
|
||||
* 2. Spring Security filters: Authorization checks based on permissions
|
||||
*
|
||||
* Infrastructure Layer → Spring Security Configuration
|
||||
*/
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
@EnableMethodSecurity(securedEnabled = true, jsr250Enabled = true)
|
||||
public class SecurityConfig {
|
||||
|
||||
private final JwtAuthenticationFilter jwtAuthenticationFilter;
|
||||
|
||||
public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) {
|
||||
this.jwtAuthenticationFilter = jwtAuthenticationFilter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configures the Spring Security filter chain.
|
||||
*
|
||||
* Security Configuration:
|
||||
* - CSRF disabled (stateless JWT - no cookie-based sessions)
|
||||
* - CORS configured (allows cross-origin requests)
|
||||
* - Session management: STATELESS (no server-side sessions)
|
||||
* - Authorization: Public endpoints vs protected endpoints
|
||||
* - Exception handling: 401 Unauthorized for authentication failures
|
||||
*
|
||||
* @param http HttpSecurity builder
|
||||
* @return Configured SecurityFilterChain
|
||||
* @throws Exception if configuration fails
|
||||
*/
|
||||
@Bean
|
||||
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
||||
http
|
||||
// CSRF Protection: Disabled for stateless JWT authentication
|
||||
// IMPORTANT: Enable CSRF for cookie-based sessions in production!
|
||||
.csrf(AbstractHttpConfigurer::disable)
|
||||
|
||||
// CORS Configuration: Allow cross-origin requests
|
||||
// TODO: Configure specific origins in production (not allowAll)
|
||||
.cors(AbstractHttpConfigurer::disable)
|
||||
|
||||
// Session Management: Stateless (no server-side sessions)
|
||||
.sessionManagement(session ->
|
||||
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
|
||||
)
|
||||
|
||||
// Authorization Rules
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
// Public Endpoints: Authentication
|
||||
.requestMatchers("/api/auth/login", "/api/auth/refresh").permitAll()
|
||||
|
||||
// Public Endpoints: Swagger/OpenAPI (restrict in production!)
|
||||
.requestMatchers("/swagger-ui/**", "/api-docs/**", "/v3/api-docs/**").permitAll()
|
||||
|
||||
// Public Endpoints: Health check (for load balancers)
|
||||
.requestMatchers("/actuator/health").permitAll()
|
||||
|
||||
// Protected Endpoints: All other /api/** endpoints require authentication
|
||||
.requestMatchers("/api/**").authenticated()
|
||||
|
||||
// All other requests: Deny by default (secure by default)
|
||||
.anyRequest().denyAll()
|
||||
)
|
||||
|
||||
// Exception Handling: Return 401 Unauthorized for authentication failures
|
||||
.exceptionHandling(exception -> exception
|
||||
.authenticationEntryPoint((request, response, authException) -> {
|
||||
response.setStatus(401);
|
||||
response.setContentType("application/json");
|
||||
response.getWriter().write(
|
||||
"{\"error\":\"Unauthorized\",\"message\":\"" +
|
||||
authException.getMessage() +
|
||||
"\"}"
|
||||
);
|
||||
})
|
||||
.accessDeniedHandler((request, response, accessDeniedException) -> {
|
||||
response.setStatus(403);
|
||||
response.setContentType("application/json");
|
||||
response.getWriter().write(
|
||||
"{\"error\":\"Forbidden\",\"message\":\"" +
|
||||
accessDeniedException.getMessage() +
|
||||
"\"}"
|
||||
);
|
||||
})
|
||||
)
|
||||
|
||||
// Add JWT Authentication Filter before Spring Security's authentication filters
|
||||
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
|
||||
|
||||
return http.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Password Encoder Bean: BCrypt with strength 12.
|
||||
*
|
||||
* BCrypt Properties:
|
||||
* - Strength 12: ~250ms hashing time on modern hardware
|
||||
* - 2^12 = 4,096 iterations
|
||||
* - Includes automatic salt generation
|
||||
* - Resistant to rainbow table and brute-force attacks
|
||||
*
|
||||
* This bean is used by:
|
||||
* - BCryptPasswordHasher (for password hashing)
|
||||
* - Spring Security (for password verification in authentication)
|
||||
*
|
||||
* @return BCryptPasswordEncoder with strength 12
|
||||
*/
|
||||
@Bean
|
||||
public PasswordEncoder passwordEncoder() {
|
||||
return new BCryptPasswordEncoder(12);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
package de.effigenix.infrastructure.security;
|
||||
|
||||
import de.effigenix.domain.usermanagement.Permission;
|
||||
import de.effigenix.shared.security.Action;
|
||||
import de.effigenix.shared.security.ActorId;
|
||||
import de.effigenix.shared.security.AuthorizationPort;
|
||||
import de.effigenix.shared.security.BranchId;
|
||||
import de.effigenix.shared.security.ResourceId;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Spring Security implementation of AuthorizationPort.
|
||||
*
|
||||
* This is the Anti-Corruption Layer (ACL) between domain logic and User Management.
|
||||
* It adapts Spring Security's authentication/authorization model to the domain's
|
||||
* type-safe, action-oriented authorization.
|
||||
*
|
||||
* The ActorId parameter makes the port framework-agnostic. This adapter still uses
|
||||
* Spring SecurityContext internally (MVP), but the interface contract is explicit.
|
||||
*
|
||||
* Authorization Flow:
|
||||
* 1. Controller extracts ActorId from Authentication
|
||||
* 2. Use Case calls: authPort.can(actorId, ProductionAction.RECIPE_WRITE)
|
||||
* 3. Adapter maps: ProductionAction.RECIPE_WRITE → Permission.RECIPE_WRITE
|
||||
* 4. Adapter checks: Does current user have Permission.RECIPE_WRITE?
|
||||
*
|
||||
* Infrastructure Layer → Implements Shared Kernel interface
|
||||
*/
|
||||
@Component
|
||||
public class SpringSecurityAuthorizationAdapter implements AuthorizationPort {
|
||||
|
||||
private final ActionToPermissionMapper actionMapper;
|
||||
|
||||
public SpringSecurityAuthorizationAdapter(ActionToPermissionMapper actionMapper) {
|
||||
this.actionMapper = actionMapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean can(ActorId actor, Action action) {
|
||||
if (actor == null || action == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Permission requiredPermission = actionMapper.mapActionToPermission(action);
|
||||
Set<Permission> userPermissions = getCurrentUserPermissions();
|
||||
|
||||
return userPermissions.contains(requiredPermission);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean can(ActorId actor, Action action, ResourceId resource) {
|
||||
if (!can(actor, action)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (resource instanceof BranchId targetBranch) {
|
||||
Optional<BranchId> userBranch = getCurrentUserBranch();
|
||||
|
||||
// If user has no branch assignment, they have global access (admin)
|
||||
if (userBranch.isEmpty()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// User can only access resources in their assigned branch
|
||||
return userBranch.get().equals(targetBranch);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private Set<Permission> getCurrentUserPermissions() {
|
||||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||
|
||||
if (authentication == null || !authentication.isAuthenticated()) {
|
||||
return Set.of();
|
||||
}
|
||||
|
||||
return authentication.getAuthorities().stream()
|
||||
.map(GrantedAuthority::getAuthority)
|
||||
.map(Permission::valueOf)
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
private Optional<BranchId> getCurrentUserBranch() {
|
||||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||
|
||||
if (authentication == null || !authentication.isAuthenticated()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
if (authentication.getDetails() instanceof JwtAuthenticationDetails details) {
|
||||
String branchId = details.getBranchId();
|
||||
return branchId != null ? Optional.of(BranchId.of(branchId)) : Optional.empty();
|
||||
}
|
||||
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
package de.effigenix.infrastructure.usermanagement.persistence.entity;
|
||||
|
||||
import de.effigenix.domain.usermanagement.Permission;
|
||||
import de.effigenix.domain.usermanagement.RoleName;
|
||||
import jakarta.persistence.*;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* JPA Entity for Role.
|
||||
* Infrastructure layer - NOT part of domain model!
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "roles")
|
||||
public class RoleEntity {
|
||||
|
||||
@Id
|
||||
@Column(name = "id", nullable = false, length = 36)
|
||||
private String id;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "name", nullable = false, unique = true, length = 50)
|
||||
private RoleName name;
|
||||
|
||||
@ElementCollection(fetch = FetchType.EAGER)
|
||||
@CollectionTable(name = "role_permissions", joinColumns = @JoinColumn(name = "role_id"))
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "permission", nullable = false, length = 100)
|
||||
private Set<Permission> permissions = new HashSet<>();
|
||||
|
||||
@Column(name = "description", length = 500)
|
||||
private String description;
|
||||
|
||||
// JPA requires no-arg constructor
|
||||
protected RoleEntity() {
|
||||
}
|
||||
|
||||
public RoleEntity(
|
||||
String id,
|
||||
RoleName name,
|
||||
Set<Permission> permissions,
|
||||
String description
|
||||
) {
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
this.permissions = permissions != null ? permissions : new HashSet<>();
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
// Getters and Setters
|
||||
public String getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(String id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public RoleName getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public void setName(RoleName name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public Set<Permission> getPermissions() {
|
||||
return permissions;
|
||||
}
|
||||
|
||||
public void setPermissions(Set<Permission> permissions) {
|
||||
this.permissions = permissions;
|
||||
}
|
||||
|
||||
public String getDescription() {
|
||||
return description;
|
||||
}
|
||||
|
||||
public void setDescription(String description) {
|
||||
this.description = description;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,154 @@
|
|||
package de.effigenix.infrastructure.usermanagement.persistence.entity;
|
||||
|
||||
import de.effigenix.domain.usermanagement.UserStatus;
|
||||
import jakarta.persistence.*;
|
||||
import org.springframework.data.annotation.CreatedDate;
|
||||
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* JPA Entity for User.
|
||||
* Infrastructure layer - NOT part of domain model!
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "users")
|
||||
@EntityListeners(AuditingEntityListener.class)
|
||||
public class UserEntity {
|
||||
|
||||
@Id
|
||||
@Column(name = "id", nullable = false, length = 36)
|
||||
private String id;
|
||||
|
||||
@Column(name = "username", nullable = false, unique = true, length = 100)
|
||||
private String username;
|
||||
|
||||
@Column(name = "email", nullable = false, unique = true, length = 255)
|
||||
private String email;
|
||||
|
||||
@Column(name = "password_hash", nullable = false, length = 60)
|
||||
private String passwordHash;
|
||||
|
||||
@ManyToMany(fetch = FetchType.EAGER)
|
||||
@JoinTable(
|
||||
name = "user_roles",
|
||||
joinColumns = @JoinColumn(name = "user_id"),
|
||||
inverseJoinColumns = @JoinColumn(name = "role_id")
|
||||
)
|
||||
private Set<RoleEntity> roles = new HashSet<>();
|
||||
|
||||
@Column(name = "branch_id", length = 36)
|
||||
private String branchId;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "status", nullable = false, length = 20)
|
||||
private UserStatus status;
|
||||
|
||||
@CreatedDate
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Column(name = "last_login")
|
||||
private LocalDateTime lastLogin;
|
||||
|
||||
// JPA requires no-arg constructor
|
||||
protected UserEntity() {
|
||||
}
|
||||
|
||||
public UserEntity(
|
||||
String id,
|
||||
String username,
|
||||
String email,
|
||||
String passwordHash,
|
||||
Set<RoleEntity> roles,
|
||||
String branchId,
|
||||
UserStatus status,
|
||||
LocalDateTime createdAt,
|
||||
LocalDateTime lastLogin
|
||||
) {
|
||||
this.id = id;
|
||||
this.username = username;
|
||||
this.email = email;
|
||||
this.passwordHash = passwordHash;
|
||||
this.roles = roles != null ? roles : new HashSet<>();
|
||||
this.branchId = branchId;
|
||||
this.status = status;
|
||||
this.createdAt = createdAt;
|
||||
this.lastLogin = lastLogin;
|
||||
}
|
||||
|
||||
// Getters and Setters
|
||||
public String getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(String id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public String getUsername() {
|
||||
return username;
|
||||
}
|
||||
|
||||
public void setUsername(String username) {
|
||||
this.username = username;
|
||||
}
|
||||
|
||||
public String getEmail() {
|
||||
return email;
|
||||
}
|
||||
|
||||
public void setEmail(String email) {
|
||||
this.email = email;
|
||||
}
|
||||
|
||||
public String getPasswordHash() {
|
||||
return passwordHash;
|
||||
}
|
||||
|
||||
public void setPasswordHash(String passwordHash) {
|
||||
this.passwordHash = passwordHash;
|
||||
}
|
||||
|
||||
public Set<RoleEntity> getRoles() {
|
||||
return roles;
|
||||
}
|
||||
|
||||
public void setRoles(Set<RoleEntity> roles) {
|
||||
this.roles = roles;
|
||||
}
|
||||
|
||||
public String getBranchId() {
|
||||
return branchId;
|
||||
}
|
||||
|
||||
public void setBranchId(String branchId) {
|
||||
this.branchId = branchId;
|
||||
}
|
||||
|
||||
public UserStatus getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public void setStatus(UserStatus status) {
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
public LocalDateTime getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public void setCreatedAt(LocalDateTime createdAt) {
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
|
||||
public LocalDateTime getLastLogin() {
|
||||
return lastLogin;
|
||||
}
|
||||
|
||||
public void setLastLogin(LocalDateTime lastLogin) {
|
||||
this.lastLogin = lastLogin;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
package de.effigenix.infrastructure.usermanagement.persistence.mapper;
|
||||
|
||||
import de.effigenix.domain.usermanagement.Role;
|
||||
import de.effigenix.domain.usermanagement.RoleId;
|
||||
import de.effigenix.infrastructure.usermanagement.persistence.entity.RoleEntity;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.HashSet;
|
||||
|
||||
/**
|
||||
* Maps between Role domain entity and RoleEntity JPA entity.
|
||||
* Infrastructure Layer - translates between Domain and Persistence layers.
|
||||
*
|
||||
* This is a crucial part of Hexagonal Architecture:
|
||||
* - Domain layer defines pure business logic (Role)
|
||||
* - Infrastructure layer handles persistence (RoleEntity)
|
||||
* - Mapper translates between the two layers
|
||||
*/
|
||||
@Component
|
||||
public class RoleMapper {
|
||||
|
||||
/**
|
||||
* Converts a Role domain entity to a RoleEntity JPA entity.
|
||||
* Used when saving to the database.
|
||||
*/
|
||||
public RoleEntity toEntity(Role role) {
|
||||
if (role == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new RoleEntity(
|
||||
role.id().value(),
|
||||
role.name(),
|
||||
new HashSet<>(role.permissions()),
|
||||
role.description()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a RoleEntity JPA entity to a Role domain entity.
|
||||
* Used when loading from the database.
|
||||
*/
|
||||
public Role toDomain(RoleEntity entity) {
|
||||
if (entity == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Role.reconstitute(
|
||||
RoleId.of(entity.getId()),
|
||||
entity.getName(),
|
||||
new HashSet<>(entity.getPermissions()),
|
||||
entity.getDescription()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
package de.effigenix.infrastructure.usermanagement.persistence.mapper;
|
||||
|
||||
import de.effigenix.domain.usermanagement.*;
|
||||
import de.effigenix.infrastructure.usermanagement.persistence.entity.RoleEntity;
|
||||
import de.effigenix.infrastructure.usermanagement.persistence.entity.UserEntity;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Maps between User domain entity and UserEntity JPA entity.
|
||||
* Infrastructure Layer - translates between Domain and Persistence layers.
|
||||
*
|
||||
* This is a crucial part of Hexagonal Architecture:
|
||||
* - Domain layer defines pure business logic (User)
|
||||
* - Infrastructure layer handles persistence (UserEntity)
|
||||
* - Mapper translates between the two layers
|
||||
*/
|
||||
@Component
|
||||
public class UserMapper {
|
||||
|
||||
private final RoleMapper roleMapper;
|
||||
|
||||
public UserMapper(RoleMapper roleMapper) {
|
||||
this.roleMapper = roleMapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a User domain entity to a UserEntity JPA entity.
|
||||
* Used when saving to the database.
|
||||
*/
|
||||
public UserEntity toEntity(User user) {
|
||||
if (user == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Set<RoleEntity> roleEntities = user.roles().stream()
|
||||
.map(roleMapper::toEntity)
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
return new UserEntity(
|
||||
user.id().value(),
|
||||
user.username(),
|
||||
user.email(),
|
||||
user.passwordHash().value(),
|
||||
roleEntities,
|
||||
user.branchId(),
|
||||
user.status(),
|
||||
user.createdAt(),
|
||||
user.lastLogin()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a UserEntity JPA entity to a User domain entity.
|
||||
* Used when loading from the database.
|
||||
*/
|
||||
public User toDomain(UserEntity entity) {
|
||||
if (entity == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Set<Role> roles = entity.getRoles() != null
|
||||
? entity.getRoles().stream()
|
||||
.map(roleMapper::toDomain)
|
||||
.collect(Collectors.toSet())
|
||||
: new HashSet<>();
|
||||
|
||||
return User.reconstitute(
|
||||
UserId.of(entity.getId()),
|
||||
entity.getUsername(),
|
||||
entity.getEmail(),
|
||||
PasswordHash.of(entity.getPasswordHash()),
|
||||
roles,
|
||||
entity.getBranchId(),
|
||||
entity.getStatus(),
|
||||
entity.getCreatedAt(),
|
||||
entity.getLastLogin()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
package de.effigenix.infrastructure.usermanagement.persistence.repository;
|
||||
|
||||
import de.effigenix.domain.usermanagement.RepositoryError;
|
||||
import de.effigenix.domain.usermanagement.Role;
|
||||
import de.effigenix.domain.usermanagement.RoleId;
|
||||
import de.effigenix.domain.usermanagement.RoleName;
|
||||
import de.effigenix.domain.usermanagement.RoleRepository;
|
||||
import de.effigenix.infrastructure.usermanagement.persistence.mapper.RoleMapper;
|
||||
import de.effigenix.shared.common.Result;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* JPA Adapter for RoleRepository (Domain Interface).
|
||||
* Infrastructure Layer - implements the Domain's RoleRepository interface.
|
||||
*
|
||||
* This is the Adapter pattern in Hexagonal Architecture:
|
||||
* - Domain defines the interface (RoleRepository)
|
||||
* - Infrastructure implements it (JpaRoleRepository)
|
||||
* - Uses Spring Data JPA (RoleJpaRepository) internally
|
||||
* - Translates between Domain and JPA entities using RoleMapper
|
||||
*
|
||||
* @Transactional ensures database consistency.
|
||||
*/
|
||||
@Repository
|
||||
@Transactional(readOnly = true)
|
||||
public class JpaRoleRepository implements RoleRepository {
|
||||
|
||||
private final RoleJpaRepository jpaRepository;
|
||||
private final RoleMapper roleMapper;
|
||||
|
||||
public JpaRoleRepository(RoleJpaRepository jpaRepository, RoleMapper roleMapper) {
|
||||
this.jpaRepository = jpaRepository;
|
||||
this.roleMapper = roleMapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Result<RepositoryError, Optional<Role>> findById(RoleId id) {
|
||||
try {
|
||||
Optional<Role> result = jpaRepository.findById(id.value())
|
||||
.map(roleMapper::toDomain);
|
||||
return Result.success(result);
|
||||
} catch (Exception e) {
|
||||
return Result.failure(new RepositoryError.DatabaseError(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Result<RepositoryError, Optional<Role>> findByName(RoleName name) {
|
||||
try {
|
||||
Optional<Role> result = jpaRepository.findByName(name)
|
||||
.map(roleMapper::toDomain);
|
||||
return Result.success(result);
|
||||
} catch (Exception e) {
|
||||
return Result.failure(new RepositoryError.DatabaseError(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Result<RepositoryError, List<Role>> findAll() {
|
||||
try {
|
||||
List<Role> result = jpaRepository.findAll().stream()
|
||||
.map(roleMapper::toDomain)
|
||||
.collect(Collectors.toList());
|
||||
return Result.success(result);
|
||||
} catch (Exception e) {
|
||||
return Result.failure(new RepositoryError.DatabaseError(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public Result<RepositoryError, Void> save(Role role) {
|
||||
try {
|
||||
jpaRepository.save(roleMapper.toEntity(role));
|
||||
return Result.success(null);
|
||||
} catch (Exception e) {
|
||||
return Result.failure(new RepositoryError.DatabaseError(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public Result<RepositoryError, Void> delete(Role role) {
|
||||
try {
|
||||
jpaRepository.deleteById(role.id().value());
|
||||
return Result.success(null);
|
||||
} catch (Exception e) {
|
||||
return Result.failure(new RepositoryError.DatabaseError(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Result<RepositoryError, Boolean> existsByName(RoleName name) {
|
||||
try {
|
||||
return Result.success(jpaRepository.existsByName(name));
|
||||
} catch (Exception e) {
|
||||
return Result.failure(new RepositoryError.DatabaseError(e.getMessage()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,149 @@
|
|||
package de.effigenix.infrastructure.usermanagement.persistence.repository;
|
||||
|
||||
import de.effigenix.domain.usermanagement.RepositoryError;
|
||||
import de.effigenix.domain.usermanagement.User;
|
||||
import de.effigenix.domain.usermanagement.UserId;
|
||||
import de.effigenix.domain.usermanagement.UserRepository;
|
||||
import de.effigenix.domain.usermanagement.UserStatus;
|
||||
import de.effigenix.infrastructure.usermanagement.persistence.mapper.UserMapper;
|
||||
import de.effigenix.shared.common.Result;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* JPA Adapter for UserRepository (Domain Interface).
|
||||
* Infrastructure Layer - implements the Domain's UserRepository interface.
|
||||
*
|
||||
* This is the Adapter pattern in Hexagonal Architecture:
|
||||
* - Domain defines the interface (UserRepository)
|
||||
* - Infrastructure implements it (JpaUserRepository)
|
||||
* - Uses Spring Data JPA (UserJpaRepository) internally
|
||||
* - Translates between Domain and JPA entities using UserMapper
|
||||
*
|
||||
* @Transactional ensures database consistency.
|
||||
*/
|
||||
@Repository
|
||||
@Transactional(readOnly = true)
|
||||
public class JpaUserRepository implements UserRepository {
|
||||
|
||||
private final UserJpaRepository jpaRepository;
|
||||
private final UserMapper userMapper;
|
||||
|
||||
public JpaUserRepository(UserJpaRepository jpaRepository, UserMapper userMapper) {
|
||||
this.jpaRepository = jpaRepository;
|
||||
this.userMapper = userMapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Result<RepositoryError, Optional<User>> findById(UserId id) {
|
||||
try {
|
||||
Optional<User> result = jpaRepository.findById(id.value())
|
||||
.map(userMapper::toDomain);
|
||||
return Result.success(result);
|
||||
} catch (Exception e) {
|
||||
return Result.failure(new RepositoryError.DatabaseError(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Result<RepositoryError, Optional<User>> findByUsername(String username) {
|
||||
try {
|
||||
Optional<User> result = jpaRepository.findByUsername(username)
|
||||
.map(userMapper::toDomain);
|
||||
return Result.success(result);
|
||||
} catch (Exception e) {
|
||||
return Result.failure(new RepositoryError.DatabaseError(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Result<RepositoryError, Optional<User>> findByEmail(String email) {
|
||||
try {
|
||||
Optional<User> result = jpaRepository.findByEmail(email)
|
||||
.map(userMapper::toDomain);
|
||||
return Result.success(result);
|
||||
} catch (Exception e) {
|
||||
return Result.failure(new RepositoryError.DatabaseError(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Result<RepositoryError, List<User>> findByBranchId(String branchId) {
|
||||
try {
|
||||
List<User> result = jpaRepository.findByBranchId(branchId).stream()
|
||||
.map(userMapper::toDomain)
|
||||
.collect(Collectors.toList());
|
||||
return Result.success(result);
|
||||
} catch (Exception e) {
|
||||
return Result.failure(new RepositoryError.DatabaseError(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Result<RepositoryError, List<User>> findByStatus(UserStatus status) {
|
||||
try {
|
||||
List<User> result = jpaRepository.findByStatus(status).stream()
|
||||
.map(userMapper::toDomain)
|
||||
.collect(Collectors.toList());
|
||||
return Result.success(result);
|
||||
} catch (Exception e) {
|
||||
return Result.failure(new RepositoryError.DatabaseError(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Result<RepositoryError, List<User>> findAll() {
|
||||
try {
|
||||
List<User> result = jpaRepository.findAll().stream()
|
||||
.map(userMapper::toDomain)
|
||||
.collect(Collectors.toList());
|
||||
return Result.success(result);
|
||||
} catch (Exception e) {
|
||||
return Result.failure(new RepositoryError.DatabaseError(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public Result<RepositoryError, Void> save(User user) {
|
||||
try {
|
||||
jpaRepository.save(userMapper.toEntity(user));
|
||||
return Result.success(null);
|
||||
} catch (Exception e) {
|
||||
return Result.failure(new RepositoryError.DatabaseError(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public Result<RepositoryError, Void> delete(User user) {
|
||||
try {
|
||||
jpaRepository.deleteById(user.id().value());
|
||||
return Result.success(null);
|
||||
} catch (Exception e) {
|
||||
return Result.failure(new RepositoryError.DatabaseError(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Result<RepositoryError, Boolean> existsByUsername(String username) {
|
||||
try {
|
||||
return Result.success(jpaRepository.existsByUsername(username));
|
||||
} catch (Exception e) {
|
||||
return Result.failure(new RepositoryError.DatabaseError(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Result<RepositoryError, Boolean> existsByEmail(String email) {
|
||||
try {
|
||||
return Result.success(jpaRepository.existsByEmail(email));
|
||||
} catch (Exception e) {
|
||||
return Result.failure(new RepositoryError.DatabaseError(e.getMessage()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
package de.effigenix.infrastructure.usermanagement.persistence.repository;
|
||||
|
||||
import de.effigenix.domain.usermanagement.RoleName;
|
||||
import de.effigenix.infrastructure.usermanagement.persistence.entity.RoleEntity;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Spring Data JPA Repository for RoleEntity.
|
||||
* Infrastructure Layer - automatically provides CRUD operations.
|
||||
*
|
||||
* Spring Data generates implementations at runtime based on method names.
|
||||
*/
|
||||
@Repository
|
||||
public interface RoleJpaRepository extends JpaRepository<RoleEntity, String> {
|
||||
|
||||
/**
|
||||
* Finds a role by its name.
|
||||
*/
|
||||
Optional<RoleEntity> findByName(RoleName name);
|
||||
|
||||
/**
|
||||
* Checks if a role with the given name exists.
|
||||
*/
|
||||
boolean existsByName(RoleName name);
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
package de.effigenix.infrastructure.usermanagement.persistence.repository;
|
||||
|
||||
import de.effigenix.domain.usermanagement.UserStatus;
|
||||
import de.effigenix.infrastructure.usermanagement.persistence.entity.UserEntity;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Spring Data JPA Repository for UserEntity.
|
||||
* Infrastructure Layer - automatically provides CRUD operations.
|
||||
*
|
||||
* Spring Data generates implementations at runtime based on method names.
|
||||
*/
|
||||
@Repository
|
||||
public interface UserJpaRepository extends JpaRepository<UserEntity, String> {
|
||||
|
||||
/**
|
||||
* Finds a user by their username.
|
||||
*/
|
||||
Optional<UserEntity> findByUsername(String username);
|
||||
|
||||
/**
|
||||
* Finds a user by their email.
|
||||
*/
|
||||
Optional<UserEntity> findByEmail(String email);
|
||||
|
||||
/**
|
||||
* Finds all users assigned to a specific branch.
|
||||
*/
|
||||
List<UserEntity> findByBranchId(String branchId);
|
||||
|
||||
/**
|
||||
* Finds all users with a specific status.
|
||||
*/
|
||||
List<UserEntity> findByStatus(UserStatus status);
|
||||
|
||||
/**
|
||||
* Checks if a username already exists.
|
||||
*/
|
||||
boolean existsByUsername(String username);
|
||||
|
||||
/**
|
||||
* Checks if an email already exists.
|
||||
*/
|
||||
boolean existsByEmail(String email);
|
||||
}
|
||||
|
|
@ -0,0 +1,378 @@
|
|||
# User Management REST API
|
||||
|
||||
This package contains the REST API layer for the User Management system.
|
||||
|
||||
## Architecture
|
||||
|
||||
Built following **Clean Architecture** principles:
|
||||
- **Controllers**: Handle HTTP requests/responses, convert DTOs
|
||||
- **DTOs**: Request/Response objects for API communication
|
||||
- **Exception Handler**: Centralized error handling
|
||||
- **OpenAPI Config**: Swagger/OpenAPI documentation
|
||||
|
||||
## Package Structure
|
||||
|
||||
```
|
||||
infrastructure/web/usermanagement/
|
||||
├── controller/
|
||||
│ ├── AuthController.java # Authentication endpoints
|
||||
│ ├── UserController.java # User management endpoints
|
||||
│ └── RoleController.java # Role management endpoints
|
||||
├── dto/
|
||||
│ ├── LoginRequest.java # Login request
|
||||
│ ├── LoginResponse.java # Login response with JWT
|
||||
│ ├── RefreshTokenRequest.java # Refresh token request
|
||||
│ ├── CreateUserRequest.java # Create user request
|
||||
│ ├── UpdateUserRequest.java # Update user request
|
||||
│ ├── ChangePasswordRequest.java # Change password request
|
||||
│ ├── AssignRoleRequest.java # Assign role request
|
||||
│ └── ErrorResponse.java # Standard error response
|
||||
├── exception/
|
||||
│ └── GlobalExceptionHandler.java # Centralized exception handling
|
||||
└── config/
|
||||
└── OpenApiConfig.java # Swagger/OpenAPI configuration
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Authentication (Public)
|
||||
|
||||
| Method | Endpoint | Description | Security |
|
||||
|--------|----------|-------------|----------|
|
||||
| POST | `/api/auth/login` | Login with username/password | Public |
|
||||
| POST | `/api/auth/logout` | Logout (invalidate JWT) | Authenticated |
|
||||
| POST | `/api/auth/refresh` | Refresh access token | Public |
|
||||
|
||||
### User Management (Authenticated)
|
||||
|
||||
| Method | Endpoint | Description | Security |
|
||||
|--------|----------|-------------|----------|
|
||||
| POST | `/api/users` | Create user | ADMIN only |
|
||||
| GET | `/api/users` | List all users | Authenticated |
|
||||
| GET | `/api/users/{id}` | Get user by ID | Authenticated |
|
||||
| PUT | `/api/users/{id}` | Update user | Authenticated |
|
||||
| POST | `/api/users/{id}/lock` | Lock user account | ADMIN only |
|
||||
| POST | `/api/users/{id}/unlock` | Unlock user account | ADMIN only |
|
||||
| POST | `/api/users/{id}/roles` | Assign role to user | ADMIN only |
|
||||
| DELETE | `/api/users/{id}/roles/{roleName}` | Remove role from user | ADMIN only |
|
||||
| PUT | `/api/users/{id}/password` | Change password | Authenticated |
|
||||
|
||||
### Role Management (ADMIN only)
|
||||
|
||||
| Method | Endpoint | Description | Security |
|
||||
|--------|----------|-------------|----------|
|
||||
| GET | `/api/roles` | List all roles | ADMIN only |
|
||||
|
||||
## Security
|
||||
|
||||
### Authentication
|
||||
- JWT-based authentication (stateless)
|
||||
- Access token + refresh token
|
||||
- Token expiration: 1 hour (configurable)
|
||||
- Refresh token for obtaining new access tokens
|
||||
|
||||
### Authorization
|
||||
- Role-based access control (RBAC)
|
||||
- Permission-based authorization via `AuthorizationPort`
|
||||
- ADMIN endpoints require `USER_MANAGEMENT` permission
|
||||
- Users can change their own password
|
||||
|
||||
### Security Configuration
|
||||
- Public endpoints: `/api/auth/login`, `/api/auth/refresh`
|
||||
- Protected endpoints: All other `/api/**` endpoints
|
||||
- JWT validation via `JwtAuthenticationFilter`
|
||||
- Authorization checks via `@PreAuthorize` annotations
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Standard Error Response Format
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "USER_NOT_FOUND",
|
||||
"message": "User with ID 'user-123' not found",
|
||||
"status": 404,
|
||||
"timestamp": "2026-02-17T12:00:00",
|
||||
"path": "/api/users/user-123",
|
||||
"validationErrors": null
|
||||
}
|
||||
```
|
||||
|
||||
### HTTP Status Codes
|
||||
|
||||
| Status | Description | Examples |
|
||||
|--------|-------------|----------|
|
||||
| 200 OK | Success | GET, PUT requests |
|
||||
| 201 Created | Resource created | POST /api/users |
|
||||
| 204 No Content | Success, no response body | Password change, logout |
|
||||
| 400 Bad Request | Validation error | Missing required fields |
|
||||
| 401 Unauthorized | Authentication failed | Invalid credentials, expired token |
|
||||
| 403 Forbidden | Authorization failed | Missing permission |
|
||||
| 404 Not Found | Resource not found | User not found |
|
||||
| 409 Conflict | Resource conflict | Username already exists |
|
||||
| 500 Internal Server Error | Unexpected error | System errors |
|
||||
|
||||
### Error Types Handled
|
||||
|
||||
1. **Domain Errors** (`UserError`)
|
||||
- `UserNotFound` → 404 Not Found
|
||||
- `UsernameAlreadyExists` → 409 Conflict
|
||||
- `EmailAlreadyExists` → 409 Conflict
|
||||
- `InvalidCredentials` → 401 Unauthorized
|
||||
- `UserLocked` → 403 Forbidden
|
||||
- `UserInactive` → 403 Forbidden
|
||||
- `RoleNotFound` → 404 Not Found
|
||||
- `InvalidPassword` → 400 Bad Request
|
||||
- `Unauthorized` → 403 Forbidden
|
||||
|
||||
2. **Validation Errors**
|
||||
- Bean Validation (`@Valid`) → 400 Bad Request
|
||||
- Returns list of field-level errors
|
||||
|
||||
3. **Authentication Errors**
|
||||
- Invalid JWT token → 401 Unauthorized
|
||||
- Expired JWT token → 401 Unauthorized
|
||||
|
||||
4. **Authorization Errors**
|
||||
- Missing permission → 403 Forbidden
|
||||
|
||||
5. **Unexpected Errors**
|
||||
- Runtime exceptions → 500 Internal Server Error
|
||||
|
||||
## API Documentation (Swagger)
|
||||
|
||||
### Access Swagger UI
|
||||
- URL: `http://localhost:8080/swagger-ui/index.html`
|
||||
- OpenAPI Spec: `http://localhost:8080/v3/api-docs`
|
||||
|
||||
### Features
|
||||
- Interactive API testing
|
||||
- Request/Response examples
|
||||
- Authentication support (Bearer token)
|
||||
- Comprehensive endpoint documentation
|
||||
|
||||
### How to Test with Swagger
|
||||
|
||||
1. **Login**
|
||||
- Go to `POST /api/auth/login`
|
||||
- Click "Try it out"
|
||||
- Enter credentials: `{"username": "admin", "password": "admin123"}`
|
||||
- Execute
|
||||
- Copy the `accessToken` from response
|
||||
|
||||
2. **Authorize**
|
||||
- Click "Authorize" button (top right)
|
||||
- Enter: `Bearer <access-token>`
|
||||
- Click "Authorize"
|
||||
|
||||
3. **Test Protected Endpoints**
|
||||
- All subsequent requests will include the JWT token
|
||||
- Test any protected endpoint (e.g., GET /api/users)
|
||||
|
||||
## Request/Response Examples
|
||||
|
||||
### Login
|
||||
**Request:**
|
||||
```json
|
||||
POST /api/auth/login
|
||||
{
|
||||
"username": "admin",
|
||||
"password": "admin123"
|
||||
}
|
||||
```
|
||||
|
||||
**Response (200 OK):**
|
||||
```json
|
||||
{
|
||||
"accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||
"tokenType": "Bearer",
|
||||
"expiresIn": 3600,
|
||||
"expiresAt": "2026-02-17T14:30:00",
|
||||
"refreshToken": "refresh-token-here"
|
||||
}
|
||||
```
|
||||
|
||||
### Create User
|
||||
**Request:**
|
||||
```json
|
||||
POST /api/users
|
||||
Authorization: Bearer <access-token>
|
||||
{
|
||||
"username": "john.doe",
|
||||
"email": "john.doe@example.com",
|
||||
"password": "SecurePass123",
|
||||
"roleNames": ["USER", "MANAGER"],
|
||||
"branchId": "BRANCH-001"
|
||||
}
|
||||
```
|
||||
|
||||
**Response (201 Created):**
|
||||
```json
|
||||
{
|
||||
"id": "user-uuid",
|
||||
"username": "john.doe",
|
||||
"email": "john.doe@example.com",
|
||||
"roles": [
|
||||
{
|
||||
"id": "role-uuid",
|
||||
"name": "USER",
|
||||
"permissions": ["INVENTORY_READ"],
|
||||
"description": "Standard user"
|
||||
}
|
||||
],
|
||||
"branchId": "BRANCH-001",
|
||||
"status": "ACTIVE",
|
||||
"createdAt": "2026-02-17T12:00:00",
|
||||
"lastLogin": null
|
||||
}
|
||||
```
|
||||
|
||||
### Error Response
|
||||
**Request:**
|
||||
```json
|
||||
POST /api/users
|
||||
Authorization: Bearer <access-token>
|
||||
{
|
||||
"username": "admin",
|
||||
"email": "admin@example.com",
|
||||
"password": "password123",
|
||||
"roleNames": ["USER"]
|
||||
}
|
||||
```
|
||||
|
||||
**Response (409 Conflict):**
|
||||
```json
|
||||
{
|
||||
"code": "USER_USERNAME_EXISTS",
|
||||
"message": "Username 'admin' already exists",
|
||||
"status": 409,
|
||||
"timestamp": "2026-02-17T12:00:00",
|
||||
"path": "/api/users",
|
||||
"validationErrors": null
|
||||
}
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **Spring Web**: REST controllers, HTTP request handling
|
||||
- **Spring Security**: JWT authentication, authorization
|
||||
- **Spring Validation**: Bean validation (`@Valid`, `@NotBlank`, etc.)
|
||||
- **SpringDoc OpenAPI**: Swagger/OpenAPI documentation
|
||||
- **SLF4J**: Logging
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Controller Responsibilities**
|
||||
- Handle HTTP concerns (requests, responses, status codes)
|
||||
- Convert between DTOs and commands
|
||||
- Delegate business logic to use cases
|
||||
- Extract ActorId from SecurityContext
|
||||
- Check authorization for sensitive operations
|
||||
|
||||
2. **DTO Design**
|
||||
- Immutable records
|
||||
- Bean validation annotations
|
||||
- OpenAPI/Swagger annotations
|
||||
- Separate request/response DTOs
|
||||
|
||||
3. **Error Handling**
|
||||
- Centralized via `GlobalExceptionHandler`
|
||||
- Consistent error response format
|
||||
- Appropriate HTTP status codes
|
||||
- Don't expose internal details in production
|
||||
|
||||
4. **Security**
|
||||
- Always extract ActorId for audit logging
|
||||
- Check authorization for ADMIN operations
|
||||
- Validate all user input
|
||||
- Use HTTPS in production
|
||||
|
||||
5. **API Design**
|
||||
- RESTful URLs
|
||||
- Proper HTTP methods (GET, POST, PUT, DELETE)
|
||||
- Idempotent operations where appropriate
|
||||
- Versioned API (future: `/api/v1/users`)
|
||||
|
||||
## Testing
|
||||
|
||||
### Manual Testing with curl
|
||||
|
||||
**Login:**
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username":"admin","password":"admin123"}'
|
||||
```
|
||||
|
||||
**List Users (with JWT):**
|
||||
```bash
|
||||
curl -X GET http://localhost:8080/api/users \
|
||||
-H "Authorization: Bearer <access-token>"
|
||||
```
|
||||
|
||||
**Create User (ADMIN):**
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/users \
|
||||
-H "Authorization: Bearer <access-token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"username": "john.doe",
|
||||
"email": "john.doe@example.com",
|
||||
"password": "SecurePass123",
|
||||
"roleNames": ["USER"],
|
||||
"branchId": "BRANCH-001"
|
||||
}'
|
||||
```
|
||||
|
||||
### Integration Testing
|
||||
- See `src/test/java/de/effigenix/infrastructure/web/usermanagement/` for integration tests
|
||||
- Uses `@SpringBootTest` and `MockMvc`
|
||||
- Tests authentication, authorization, error handling
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
1. **API Versioning**: `/api/v1/users`
|
||||
2. **Rate Limiting**: Prevent API abuse
|
||||
3. **CORS Configuration**: Configure allowed origins
|
||||
4. **Request Logging**: Log all API requests
|
||||
5. **Response Caching**: Cache GET requests
|
||||
6. **Pagination**: For list endpoints
|
||||
7. **Filtering/Sorting**: Query parameters for list endpoints
|
||||
8. **HATEOAS**: Add hypermedia links to responses
|
||||
9. **GraphQL**: Alternative to REST for complex queries
|
||||
10. **WebSocket**: For real-time updates
|
||||
|
||||
## Production Considerations
|
||||
|
||||
1. **Security**
|
||||
- Enable HTTPS (TLS/SSL)
|
||||
- Restrict Swagger UI access
|
||||
- Configure CORS properly
|
||||
- Enable CSRF protection (if using cookies)
|
||||
- Use secure JWT signing key (store in environment variables)
|
||||
|
||||
2. **Performance**
|
||||
- Enable response compression (GZIP)
|
||||
- Add caching headers
|
||||
- Use connection pooling
|
||||
- Monitor response times
|
||||
|
||||
3. **Monitoring**
|
||||
- Add Spring Actuator endpoints
|
||||
- Configure metrics (Prometheus/Grafana)
|
||||
- Add distributed tracing (Zipkin/Jaeger)
|
||||
- Log all errors with correlation IDs
|
||||
|
||||
4. **Error Handling**
|
||||
- Don't expose stack traces in responses
|
||||
- Generic error messages for security
|
||||
- Log detailed errors server-side
|
||||
- Add error tracking (Sentry/Rollbar)
|
||||
|
||||
## References
|
||||
|
||||
- [Spring Web Documentation](https://spring.io/guides/gs/rest-service/)
|
||||
- [Spring Security JWT](https://spring.io/guides/tutorials/spring-boot-oauth2/)
|
||||
- [SpringDoc OpenAPI](https://springdoc.org/)
|
||||
- [RESTful API Design Best Practices](https://restfulapi.net/)
|
||||
- [Clean Architecture](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html)
|
||||
|
|
@ -0,0 +1,264 @@
|
|||
package de.effigenix.infrastructure.usermanagement.web.controller;
|
||||
|
||||
import de.effigenix.application.usermanagement.AuthenticateUser;
|
||||
import de.effigenix.application.usermanagement.SessionManager;
|
||||
import de.effigenix.application.usermanagement.command.AuthenticateCommand;
|
||||
import de.effigenix.application.usermanagement.dto.SessionToken;
|
||||
import de.effigenix.domain.usermanagement.UserError;
|
||||
import de.effigenix.infrastructure.usermanagement.web.dto.LoginRequest;
|
||||
import de.effigenix.infrastructure.usermanagement.web.dto.LoginResponse;
|
||||
import de.effigenix.infrastructure.usermanagement.web.dto.RefreshTokenRequest;
|
||||
import de.effigenix.shared.common.Result;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.media.Content;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.validation.Valid;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
/**
|
||||
* REST Controller for Authentication endpoints.
|
||||
*
|
||||
* Endpoints:
|
||||
* - POST /api/auth/login - Login with username/password, returns JWT
|
||||
* - POST /api/auth/logout - Logout (invalidate JWT)
|
||||
* - POST /api/auth/refresh - Refresh access token using refresh token
|
||||
*
|
||||
* Security:
|
||||
* - All endpoints are PUBLIC (configured in SecurityConfig)
|
||||
* - No authentication required for login/refresh
|
||||
* - Logout requires valid JWT token
|
||||
*
|
||||
* Infrastructure Layer → REST API
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/auth")
|
||||
@Tag(name = "Authentication", description = "Authentication and session management endpoints")
|
||||
public class AuthController {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(AuthController.class);
|
||||
|
||||
private final AuthenticateUser authenticateUser;
|
||||
private final SessionManager sessionManager;
|
||||
|
||||
public AuthController(
|
||||
AuthenticateUser authenticateUser,
|
||||
SessionManager sessionManager
|
||||
) {
|
||||
this.authenticateUser = authenticateUser;
|
||||
this.sessionManager = sessionManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Login endpoint.
|
||||
*
|
||||
* Authenticates user with username and password.
|
||||
* Returns JWT access token and refresh token on success.
|
||||
*
|
||||
* POST /api/auth/login
|
||||
*
|
||||
* Request Body:
|
||||
* {
|
||||
* "username": "admin",
|
||||
* "password": "admin123"
|
||||
* }
|
||||
*
|
||||
* Response (200 OK):
|
||||
* {
|
||||
* "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||
* "tokenType": "Bearer",
|
||||
* "expiresIn": 3600,
|
||||
* "expiresAt": "2026-02-17T14:30:00",
|
||||
* "refreshToken": "refresh-token-here"
|
||||
* }
|
||||
*
|
||||
* Error Responses:
|
||||
* - 401 Unauthorized: Invalid credentials, user locked, or user inactive
|
||||
* - 400 Bad Request: Validation error (missing username/password)
|
||||
*
|
||||
* @param request Login request with username and password
|
||||
* @return LoginResponse with JWT tokens
|
||||
*/
|
||||
@PostMapping("/login")
|
||||
@Operation(
|
||||
summary = "User login",
|
||||
description = "Authenticate user with username and password. Returns JWT access token and refresh token."
|
||||
)
|
||||
@ApiResponses({
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Login successful",
|
||||
content = @Content(schema = @Schema(implementation = LoginResponse.class))
|
||||
),
|
||||
@ApiResponse(
|
||||
responseCode = "401",
|
||||
description = "Invalid credentials, user locked, or user inactive"
|
||||
),
|
||||
@ApiResponse(
|
||||
responseCode = "400",
|
||||
description = "Validation error (missing username or password)"
|
||||
)
|
||||
})
|
||||
public ResponseEntity<LoginResponse> login(@Valid @RequestBody LoginRequest request) {
|
||||
logger.info("Login attempt for username: {}", request.username());
|
||||
|
||||
// Execute authentication use case
|
||||
AuthenticateCommand command = new AuthenticateCommand(
|
||||
request.username(),
|
||||
request.password()
|
||||
);
|
||||
|
||||
Result<UserError, SessionToken> result = authenticateUser.execute(command);
|
||||
|
||||
// Handle result
|
||||
if (result.isFailure()) {
|
||||
// Throw the domain error - will be handled by GlobalExceptionHandler
|
||||
UserError error = result.unsafeGetError();
|
||||
throw new AuthenticationFailedException(error);
|
||||
}
|
||||
|
||||
SessionToken token = result.unsafeGetValue();
|
||||
logger.info("Login successful for username: {}", request.username());
|
||||
|
||||
return ResponseEntity.ok(LoginResponse.from(token));
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout endpoint.
|
||||
*
|
||||
* Invalidates the current JWT token.
|
||||
* Client should also delete the token from local storage.
|
||||
*
|
||||
* POST /api/auth/logout
|
||||
* Authorization: Bearer <access-token>
|
||||
*
|
||||
* Response (204 No Content):
|
||||
* (empty body)
|
||||
*
|
||||
* @param authentication Current authentication (from JWT token)
|
||||
* @return Empty response with 204 status
|
||||
*/
|
||||
@PostMapping("/logout")
|
||||
@Operation(
|
||||
summary = "User logout",
|
||||
description = "Invalidate current JWT token. Requires authentication."
|
||||
)
|
||||
@ApiResponses({
|
||||
@ApiResponse(
|
||||
responseCode = "204",
|
||||
description = "Logout successful"
|
||||
),
|
||||
@ApiResponse(
|
||||
responseCode = "401",
|
||||
description = "Invalid or missing authentication token"
|
||||
)
|
||||
})
|
||||
public ResponseEntity<Void> logout(HttpServletRequest request, Authentication authentication) {
|
||||
String token = extractTokenFromRequest(request);
|
||||
if (token != null) {
|
||||
sessionManager.invalidateSession(token);
|
||||
String username = authentication != null ? authentication.getName() : "unknown";
|
||||
logger.info("Logout successful for user: {}", username);
|
||||
}
|
||||
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
private String extractTokenFromRequest(HttpServletRequest request) {
|
||||
String authHeader = request.getHeader("Authorization");
|
||||
if (authHeader != null && authHeader.startsWith("Bearer ")) {
|
||||
return authHeader.substring(7);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh token endpoint.
|
||||
*
|
||||
* Refreshes an expired access token using a valid refresh token.
|
||||
* Returns new JWT access token and refresh token.
|
||||
*
|
||||
* POST /api/auth/refresh
|
||||
*
|
||||
* Request Body:
|
||||
* {
|
||||
* "refreshToken": "refresh-token-here"
|
||||
* }
|
||||
*
|
||||
* Response (200 OK):
|
||||
* {
|
||||
* "accessToken": "new-access-token",
|
||||
* "tokenType": "Bearer",
|
||||
* "expiresIn": 3600,
|
||||
* "expiresAt": "2026-02-17T15:30:00",
|
||||
* "refreshToken": "new-refresh-token"
|
||||
* }
|
||||
*
|
||||
* Error Responses:
|
||||
* - 401 Unauthorized: Invalid or expired refresh token
|
||||
* - 400 Bad Request: Validation error (missing refresh token)
|
||||
*
|
||||
* @param request Refresh token request
|
||||
* @return LoginResponse with new JWT tokens
|
||||
*/
|
||||
@PostMapping("/refresh")
|
||||
@Operation(
|
||||
summary = "Refresh access token",
|
||||
description = "Refresh an expired access token using a valid refresh token. Returns new access token and refresh token."
|
||||
)
|
||||
@ApiResponses({
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Token refresh successful",
|
||||
content = @Content(schema = @Schema(implementation = LoginResponse.class))
|
||||
),
|
||||
@ApiResponse(
|
||||
responseCode = "401",
|
||||
description = "Invalid or expired refresh token"
|
||||
),
|
||||
@ApiResponse(
|
||||
responseCode = "400",
|
||||
description = "Validation error (missing refresh token)"
|
||||
)
|
||||
})
|
||||
public ResponseEntity<LoginResponse> refresh(@Valid @RequestBody RefreshTokenRequest request) {
|
||||
logger.info("Token refresh attempt");
|
||||
|
||||
try {
|
||||
SessionToken token = sessionManager.refreshSession(request.refreshToken());
|
||||
logger.info("Token refresh successful");
|
||||
|
||||
return ResponseEntity.ok(LoginResponse.from(token));
|
||||
} catch (RuntimeException ex) {
|
||||
logger.warn("Token refresh failed: {}", ex.getMessage());
|
||||
throw new AuthenticationFailedException(
|
||||
new UserError.InvalidCredentials()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom runtime exception to wrap UserError for authentication failures.
|
||||
* This allows the GlobalExceptionHandler to catch and convert it properly.
|
||||
*/
|
||||
public static class AuthenticationFailedException extends RuntimeException {
|
||||
private final UserError error;
|
||||
|
||||
public AuthenticationFailedException(UserError error) {
|
||||
super(error.message());
|
||||
this.error = error;
|
||||
}
|
||||
|
||||
public UserError getError() {
|
||||
return error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,132 @@
|
|||
package de.effigenix.infrastructure.usermanagement.web.controller;
|
||||
|
||||
import de.effigenix.application.usermanagement.dto.RoleDTO;
|
||||
import de.effigenix.domain.usermanagement.RepositoryError;
|
||||
import de.effigenix.domain.usermanagement.Role;
|
||||
import de.effigenix.domain.usermanagement.RoleRepository;
|
||||
import de.effigenix.shared.common.Result;
|
||||
import de.effigenix.shared.security.ActorId;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.media.Content;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* REST Controller for Role Management endpoints.
|
||||
*
|
||||
* Endpoints:
|
||||
* - GET /api/roles - List all roles (ADMIN only)
|
||||
*
|
||||
* Security:
|
||||
* - All endpoints require authentication (JWT token)
|
||||
* - ADMIN-only endpoints check for USER_MANAGEMENT permission
|
||||
*
|
||||
* Roles are reference data - typically loaded from seed data (Liquibase).
|
||||
* This controller provides read-only access to roles for user assignment.
|
||||
*
|
||||
* Infrastructure Layer → REST API
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/roles")
|
||||
@SecurityRequirement(name = "Bearer Authentication")
|
||||
@Tag(name = "Role Management", description = "Role management endpoints (ADMIN only)")
|
||||
public class RoleController {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(RoleController.class);
|
||||
|
||||
private final RoleRepository roleRepository;
|
||||
|
||||
public RoleController(RoleRepository roleRepository) {
|
||||
this.roleRepository = roleRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
* List all roles endpoint.
|
||||
*
|
||||
* Lists all available roles in the system.
|
||||
* Used for role assignment dropdowns in UI.
|
||||
*
|
||||
* Requires ADMIN permission (USER_MANAGEMENT).
|
||||
*
|
||||
* GET /api/roles
|
||||
* Authorization: Bearer <access-token>
|
||||
*
|
||||
* Response (200 OK):
|
||||
* [
|
||||
* {
|
||||
* "id": "role-uuid",
|
||||
* "name": "ADMIN",
|
||||
* "permissions": ["USER_MANAGEMENT", "INVENTORY_MANAGEMENT", ...],
|
||||
* "description": "System administrator with full access"
|
||||
* },
|
||||
* {
|
||||
* "id": "role-uuid-2",
|
||||
* "name": "MANAGER",
|
||||
* "permissions": ["INVENTORY_READ", "INVENTORY_WRITE", ...],
|
||||
* "description": "Branch manager with inventory management"
|
||||
* }
|
||||
* ]
|
||||
*
|
||||
* @param authentication Current authentication
|
||||
* @return List of role DTOs
|
||||
*/
|
||||
@GetMapping
|
||||
@PreAuthorize("hasAuthority('ROLE_READ')")
|
||||
@Operation(
|
||||
summary = "List all roles (ADMIN only)",
|
||||
description = "Get a list of all available roles in the system. Requires USER_MANAGEMENT permission."
|
||||
)
|
||||
@ApiResponses({
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Roles retrieved successfully",
|
||||
content = @Content(schema = @Schema(implementation = RoleDTO.class))
|
||||
),
|
||||
@ApiResponse(responseCode = "403", description = "Missing USER_MANAGEMENT permission"),
|
||||
@ApiResponse(responseCode = "401", description = "Authentication required")
|
||||
})
|
||||
public ResponseEntity<List<RoleDTO>> listRoles(Authentication authentication) {
|
||||
ActorId actorId = extractActorId(authentication);
|
||||
logger.info("Listing roles by actor: {}", actorId.value());
|
||||
|
||||
return switch (roleRepository.findAll()) {
|
||||
case Result.Failure<RepositoryError, List<Role>> f -> {
|
||||
logger.error("Failed to load roles: {}", f.error().message());
|
||||
yield ResponseEntity.internalServerError().build();
|
||||
}
|
||||
case Result.Success<RepositoryError, List<Role>> s -> {
|
||||
List<RoleDTO> roles = s.value().stream()
|
||||
.map(RoleDTO::from)
|
||||
.collect(Collectors.toList());
|
||||
logger.info("Found {} roles", roles.size());
|
||||
yield ResponseEntity.ok(roles);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== Helper Methods ====================
|
||||
|
||||
/**
|
||||
* Extracts ActorId from Spring Security Authentication.
|
||||
*/
|
||||
private ActorId extractActorId(Authentication authentication) {
|
||||
if (authentication == null || authentication.getName() == null) {
|
||||
throw new IllegalStateException("No authentication found in SecurityContext");
|
||||
}
|
||||
return ActorId.of(authentication.getName());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,677 @@
|
|||
package de.effigenix.infrastructure.usermanagement.web.controller;
|
||||
|
||||
import de.effigenix.application.usermanagement.*;
|
||||
import de.effigenix.application.usermanagement.command.AssignRoleCommand;
|
||||
import de.effigenix.application.usermanagement.command.ChangePasswordCommand;
|
||||
import de.effigenix.application.usermanagement.command.CreateUserCommand;
|
||||
import de.effigenix.application.usermanagement.command.UpdateUserCommand;
|
||||
import de.effigenix.application.usermanagement.dto.UserDTO;
|
||||
import de.effigenix.domain.usermanagement.RoleName;
|
||||
import de.effigenix.domain.usermanagement.User;
|
||||
import de.effigenix.domain.usermanagement.UserError;
|
||||
import de.effigenix.infrastructure.usermanagement.web.dto.*;
|
||||
import de.effigenix.shared.common.Result;
|
||||
import de.effigenix.shared.security.ActorId;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.media.Content;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
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.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* REST Controller for User Management endpoints.
|
||||
*
|
||||
* Endpoints:
|
||||
* - POST /api/users - Create user (ADMIN only)
|
||||
* - GET /api/users - List all users
|
||||
* - GET /api/users/{id} - Get user by ID
|
||||
* - PUT /api/users/{id} - Update user
|
||||
* - POST /api/users/{id}/lock - Lock user (ADMIN only)
|
||||
* - POST /api/users/{id}/unlock - Unlock user (ADMIN only)
|
||||
* - POST /api/users/{id}/roles - Assign role (ADMIN only)
|
||||
* - DELETE /api/users/{id}/roles/{roleName} - Remove role (ADMIN only)
|
||||
* - PUT /api/users/{id}/password - Change password
|
||||
*
|
||||
* Security:
|
||||
* - All endpoints require authentication (JWT token)
|
||||
* - ADMIN-only endpoints check for USER_MANAGEMENT permission
|
||||
* - Users can change their own password
|
||||
*
|
||||
* Infrastructure Layer → REST API
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/users")
|
||||
@SecurityRequirement(name = "Bearer Authentication")
|
||||
@Tag(name = "User Management", description = "User management endpoints (requires authentication)")
|
||||
public class UserController {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(UserController.class);
|
||||
|
||||
private final CreateUser createUser;
|
||||
private final GetUser getUser;
|
||||
private final ListUsers listUsers;
|
||||
private final UpdateUser updateUser;
|
||||
private final LockUser lockUser;
|
||||
private final UnlockUser unlockUser;
|
||||
private final AssignRole assignRole;
|
||||
private final RemoveRole removeRole;
|
||||
private final ChangePassword changePassword;
|
||||
|
||||
public UserController(
|
||||
CreateUser createUser,
|
||||
GetUser getUser,
|
||||
ListUsers listUsers,
|
||||
UpdateUser updateUser,
|
||||
LockUser lockUser,
|
||||
UnlockUser unlockUser,
|
||||
AssignRole assignRole,
|
||||
RemoveRole removeRole,
|
||||
ChangePassword changePassword
|
||||
) {
|
||||
this.createUser = createUser;
|
||||
this.getUser = getUser;
|
||||
this.listUsers = listUsers;
|
||||
this.updateUser = updateUser;
|
||||
this.lockUser = lockUser;
|
||||
this.unlockUser = unlockUser;
|
||||
this.assignRole = assignRole;
|
||||
this.removeRole = removeRole;
|
||||
this.changePassword = changePassword;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create user endpoint.
|
||||
*
|
||||
* Creates a new user account with specified roles.
|
||||
* Requires ADMIN permission (USER_MANAGEMENT).
|
||||
*
|
||||
* POST /api/users
|
||||
* Authorization: Bearer <access-token>
|
||||
*
|
||||
* Request Body:
|
||||
* {
|
||||
* "username": "john.doe",
|
||||
* "email": "john.doe@example.com",
|
||||
* "password": "SecurePass123",
|
||||
* "roleNames": ["USER", "MANAGER"],
|
||||
* "branchId": "BRANCH-001"
|
||||
* }
|
||||
*
|
||||
* Response (201 Created):
|
||||
* {
|
||||
* "id": "user-uuid",
|
||||
* "username": "john.doe",
|
||||
* "email": "john.doe@example.com",
|
||||
* "roles": [...],
|
||||
* "branchId": "BRANCH-001",
|
||||
* "status": "ACTIVE",
|
||||
* "createdAt": "2026-02-17T12:00:00",
|
||||
* "lastLogin": null
|
||||
* }
|
||||
*
|
||||
* @param request Create user request
|
||||
* @param authentication Current authentication
|
||||
* @return Created user DTO
|
||||
*/
|
||||
@PostMapping
|
||||
@PreAuthorize("hasAuthority('USER_WRITE')")
|
||||
@Operation(
|
||||
summary = "Create user (ADMIN only)",
|
||||
description = "Create a new user account with specified roles. Requires USER_MANAGEMENT permission."
|
||||
)
|
||||
@ApiResponses({
|
||||
@ApiResponse(
|
||||
responseCode = "201",
|
||||
description = "User created successfully",
|
||||
content = @Content(schema = @Schema(implementation = UserDTO.class))
|
||||
),
|
||||
@ApiResponse(responseCode = "400", description = "Validation error or invalid password"),
|
||||
@ApiResponse(responseCode = "403", description = "Missing USER_MANAGEMENT permission"),
|
||||
@ApiResponse(responseCode = "409", description = "Username or email already exists")
|
||||
})
|
||||
public ResponseEntity<UserDTO> createUser(
|
||||
@Valid @RequestBody CreateUserRequest request,
|
||||
Authentication authentication
|
||||
) {
|
||||
ActorId actorId = extractActorId(authentication);
|
||||
logger.info("Creating user: {} by actor: {}", request.username(), actorId.value());
|
||||
|
||||
// Note: Authorization is checked via @PreAuthorize annotation
|
||||
// No need for additional manual authorization check here
|
||||
|
||||
// Execute use case
|
||||
CreateUserCommand command = new CreateUserCommand(
|
||||
request.username(),
|
||||
request.email(),
|
||||
request.password(),
|
||||
request.roleNames(),
|
||||
request.branchId()
|
||||
);
|
||||
|
||||
Result<UserError, UserDTO> result = createUser.execute(command, actorId);
|
||||
|
||||
if (result.isFailure()) {
|
||||
throw new DomainErrorException(result.unsafeGetError());
|
||||
}
|
||||
|
||||
logger.info("User created successfully: {}", request.username());
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(result.unsafeGetValue());
|
||||
}
|
||||
|
||||
/**
|
||||
* List users endpoint.
|
||||
*
|
||||
* Lists all users in the system.
|
||||
* Returns simplified user information.
|
||||
*
|
||||
* GET /api/users
|
||||
* Authorization: Bearer <access-token>
|
||||
*
|
||||
* Response (200 OK):
|
||||
* [
|
||||
* {
|
||||
* "id": "user-uuid",
|
||||
* "username": "john.doe",
|
||||
* "email": "john.doe@example.com",
|
||||
* "roles": [...],
|
||||
* "branchId": "BRANCH-001",
|
||||
* "status": "ACTIVE",
|
||||
* "createdAt": "2026-02-17T12:00:00",
|
||||
* "lastLogin": "2026-02-17T14:30:00"
|
||||
* }
|
||||
* ]
|
||||
*
|
||||
* @param authentication Current authentication
|
||||
* @return List of user DTOs
|
||||
*/
|
||||
@GetMapping
|
||||
@Operation(
|
||||
summary = "List all users",
|
||||
description = "Get a list of all users in the system. Requires authentication."
|
||||
)
|
||||
@ApiResponses({
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Users retrieved successfully",
|
||||
content = @Content(schema = @Schema(implementation = UserDTO.class))
|
||||
),
|
||||
@ApiResponse(responseCode = "401", description = "Authentication required")
|
||||
})
|
||||
public ResponseEntity<List<UserDTO>> listUsers(Authentication authentication) {
|
||||
ActorId actorId = extractActorId(authentication);
|
||||
logger.info("Listing users by actor: {}", actorId.value());
|
||||
|
||||
Result<UserError, List<UserDTO>> result = listUsers.execute();
|
||||
|
||||
if (result.isFailure()) {
|
||||
throw new DomainErrorException(result.unsafeGetError());
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(result.unsafeGetValue());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user by ID endpoint.
|
||||
*
|
||||
* Retrieves a single user by their ID.
|
||||
*
|
||||
* GET /api/users/{id}
|
||||
* Authorization: Bearer <access-token>
|
||||
*
|
||||
* Response (200 OK):
|
||||
* {
|
||||
* "id": "user-uuid",
|
||||
* "username": "john.doe",
|
||||
* "email": "john.doe@example.com",
|
||||
* "roles": [...],
|
||||
* "branchId": "BRANCH-001",
|
||||
* "status": "ACTIVE",
|
||||
* "createdAt": "2026-02-17T12:00:00",
|
||||
* "lastLogin": "2026-02-17T14:30:00"
|
||||
* }
|
||||
*
|
||||
* @param userId User ID
|
||||
* @param authentication Current authentication
|
||||
* @return User DTO
|
||||
*/
|
||||
@GetMapping("/{id}")
|
||||
@Operation(
|
||||
summary = "Get user by ID",
|
||||
description = "Retrieve a single user by their ID. Requires authentication."
|
||||
)
|
||||
@ApiResponses({
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "User retrieved successfully",
|
||||
content = @Content(schema = @Schema(implementation = UserDTO.class))
|
||||
),
|
||||
@ApiResponse(responseCode = "404", description = "User not found"),
|
||||
@ApiResponse(responseCode = "401", description = "Authentication required")
|
||||
})
|
||||
public ResponseEntity<UserDTO> getUserById(
|
||||
@Parameter(description = "User ID", example = "user-uuid")
|
||||
@PathVariable("id") String userId,
|
||||
Authentication authentication
|
||||
) {
|
||||
ActorId actorId = extractActorId(authentication);
|
||||
logger.info("Getting user: {} by actor: {}", userId, actorId.value());
|
||||
|
||||
Result<UserError, UserDTO> result = getUser.execute(userId);
|
||||
|
||||
if (result.isFailure()) {
|
||||
throw new DomainErrorException(result.unsafeGetError());
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(result.unsafeGetValue());
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user endpoint.
|
||||
*
|
||||
* Updates user details (email, branchId).
|
||||
* Only provided fields will be updated.
|
||||
*
|
||||
* PUT /api/users/{id}
|
||||
* Authorization: Bearer <access-token>
|
||||
*
|
||||
* Request Body:
|
||||
* {
|
||||
* "email": "newemail@example.com",
|
||||
* "branchId": "BRANCH-002"
|
||||
* }
|
||||
*
|
||||
* Response (200 OK):
|
||||
* {
|
||||
* "id": "user-uuid",
|
||||
* "username": "john.doe",
|
||||
* "email": "newemail@example.com",
|
||||
* "roles": [...],
|
||||
* "branchId": "BRANCH-002",
|
||||
* "status": "ACTIVE",
|
||||
* "createdAt": "2026-02-17T12:00:00",
|
||||
* "lastLogin": "2026-02-17T14:30:00"
|
||||
* }
|
||||
*
|
||||
* @param userId User ID
|
||||
* @param request Update user request
|
||||
* @param authentication Current authentication
|
||||
* @return Updated user DTO
|
||||
*/
|
||||
@PutMapping("/{id}")
|
||||
@Operation(
|
||||
summary = "Update user",
|
||||
description = "Update user details (email, branchId). Only provided fields will be updated."
|
||||
)
|
||||
@ApiResponses({
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "User updated successfully",
|
||||
content = @Content(schema = @Schema(implementation = UserDTO.class))
|
||||
),
|
||||
@ApiResponse(responseCode = "404", description = "User not found"),
|
||||
@ApiResponse(responseCode = "409", description = "Email already exists"),
|
||||
@ApiResponse(responseCode = "401", description = "Authentication required")
|
||||
})
|
||||
public ResponseEntity<UserDTO> updateUser(
|
||||
@Parameter(description = "User ID", example = "user-uuid")
|
||||
@PathVariable("id") String userId,
|
||||
@Valid @RequestBody UpdateUserRequest request,
|
||||
Authentication authentication
|
||||
) {
|
||||
ActorId actorId = extractActorId(authentication);
|
||||
logger.info("Updating user: {} by actor: {}", userId, actorId.value());
|
||||
|
||||
UpdateUserCommand command = new UpdateUserCommand(
|
||||
userId,
|
||||
request.email(),
|
||||
request.branchId()
|
||||
);
|
||||
|
||||
Result<UserError, UserDTO> result = updateUser.execute(command, actorId);
|
||||
|
||||
if (result.isFailure()) {
|
||||
throw new DomainErrorException(result.unsafeGetError());
|
||||
}
|
||||
|
||||
logger.info("User updated successfully: {}", userId);
|
||||
return ResponseEntity.ok(result.unsafeGetValue());
|
||||
}
|
||||
|
||||
/**
|
||||
* Lock user endpoint.
|
||||
*
|
||||
* Locks a user account (prevents login).
|
||||
* Requires ADMIN permission (USER_MANAGEMENT).
|
||||
*
|
||||
* POST /api/users/{id}/lock
|
||||
* Authorization: Bearer <access-token>
|
||||
*
|
||||
* Response (200 OK):
|
||||
* {
|
||||
* "id": "user-uuid",
|
||||
* "username": "john.doe",
|
||||
* "email": "john.doe@example.com",
|
||||
* "roles": [...],
|
||||
* "branchId": "BRANCH-001",
|
||||
* "status": "LOCKED",
|
||||
* "createdAt": "2026-02-17T12:00:00",
|
||||
* "lastLogin": "2026-02-17T14:30:00"
|
||||
* }
|
||||
*
|
||||
* @param userId User ID
|
||||
* @param authentication Current authentication
|
||||
* @return Updated user DTO
|
||||
*/
|
||||
@PostMapping("/{id}/lock")
|
||||
@PreAuthorize("hasAuthority('USER_LOCK')")
|
||||
@Operation(
|
||||
summary = "Lock user (ADMIN only)",
|
||||
description = "Lock a user account (prevents login). Requires USER_MANAGEMENT permission."
|
||||
)
|
||||
@ApiResponses({
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "User locked successfully",
|
||||
content = @Content(schema = @Schema(implementation = UserDTO.class))
|
||||
),
|
||||
@ApiResponse(responseCode = "404", description = "User not found"),
|
||||
@ApiResponse(responseCode = "403", description = "Missing USER_MANAGEMENT permission"),
|
||||
@ApiResponse(responseCode = "401", description = "Authentication required")
|
||||
})
|
||||
public ResponseEntity<UserDTO> lockUser(
|
||||
@Parameter(description = "User ID", example = "user-uuid")
|
||||
@PathVariable("id") String userId,
|
||||
Authentication authentication
|
||||
) {
|
||||
ActorId actorId = extractActorId(authentication);
|
||||
logger.info("Locking user: {} by actor: {}", userId, actorId.value());
|
||||
|
||||
Result<UserError, UserDTO> result = lockUser.execute(userId, actorId);
|
||||
|
||||
if (result.isFailure()) {
|
||||
throw new DomainErrorException(result.unsafeGetError());
|
||||
}
|
||||
|
||||
logger.info("User locked successfully: {}", userId);
|
||||
return ResponseEntity.ok(result.unsafeGetValue());
|
||||
}
|
||||
|
||||
/**
|
||||
* Unlock user endpoint.
|
||||
*
|
||||
* Unlocks a user account (allows login).
|
||||
* Requires ADMIN permission (USER_MANAGEMENT).
|
||||
*
|
||||
* POST /api/users/{id}/unlock
|
||||
* Authorization: Bearer <access-token>
|
||||
*
|
||||
* Response (200 OK):
|
||||
* {
|
||||
* "id": "user-uuid",
|
||||
* "username": "john.doe",
|
||||
* "email": "john.doe@example.com",
|
||||
* "roles": [...],
|
||||
* "branchId": "BRANCH-001",
|
||||
* "status": "ACTIVE",
|
||||
* "createdAt": "2026-02-17T12:00:00",
|
||||
* "lastLogin": "2026-02-17T14:30:00"
|
||||
* }
|
||||
*
|
||||
* @param userId User ID
|
||||
* @param authentication Current authentication
|
||||
* @return Updated user DTO
|
||||
*/
|
||||
@PostMapping("/{id}/unlock")
|
||||
@PreAuthorize("hasAuthority('USER_UNLOCK')")
|
||||
@Operation(
|
||||
summary = "Unlock user (ADMIN only)",
|
||||
description = "Unlock a user account (allows login). Requires USER_MANAGEMENT permission."
|
||||
)
|
||||
@ApiResponses({
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "User unlocked successfully",
|
||||
content = @Content(schema = @Schema(implementation = UserDTO.class))
|
||||
),
|
||||
@ApiResponse(responseCode = "404", description = "User not found"),
|
||||
@ApiResponse(responseCode = "403", description = "Missing USER_MANAGEMENT permission"),
|
||||
@ApiResponse(responseCode = "401", description = "Authentication required")
|
||||
})
|
||||
public ResponseEntity<UserDTO> unlockUser(
|
||||
@Parameter(description = "User ID", example = "user-uuid")
|
||||
@PathVariable("id") String userId,
|
||||
Authentication authentication
|
||||
) {
|
||||
ActorId actorId = extractActorId(authentication);
|
||||
logger.info("Unlocking user: {} by actor: {}", userId, actorId.value());
|
||||
|
||||
Result<UserError, UserDTO> result = unlockUser.execute(userId, actorId);
|
||||
|
||||
if (result.isFailure()) {
|
||||
throw new DomainErrorException(result.unsafeGetError());
|
||||
}
|
||||
|
||||
logger.info("User unlocked successfully: {}", userId);
|
||||
return ResponseEntity.ok(result.unsafeGetValue());
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign role endpoint.
|
||||
*
|
||||
* Assigns a role to a user.
|
||||
* Requires ADMIN permission (USER_MANAGEMENT).
|
||||
*
|
||||
* POST /api/users/{id}/roles
|
||||
* Authorization: Bearer <access-token>
|
||||
*
|
||||
* Request Body:
|
||||
* {
|
||||
* "roleName": "MANAGER"
|
||||
* }
|
||||
*
|
||||
* Response (200 OK):
|
||||
* {
|
||||
* "id": "user-uuid",
|
||||
* "username": "john.doe",
|
||||
* "email": "john.doe@example.com",
|
||||
* "roles": [...],
|
||||
* "branchId": "BRANCH-001",
|
||||
* "status": "ACTIVE",
|
||||
* "createdAt": "2026-02-17T12:00:00",
|
||||
* "lastLogin": "2026-02-17T14:30:00"
|
||||
* }
|
||||
*
|
||||
* @param userId User ID
|
||||
* @param request Assign role request
|
||||
* @param authentication Current authentication
|
||||
* @return Updated user DTO
|
||||
*/
|
||||
@PostMapping("/{id}/roles")
|
||||
@PreAuthorize("hasAuthority('ROLE_ASSIGN')")
|
||||
@Operation(
|
||||
summary = "Assign role (ADMIN only)",
|
||||
description = "Assign a role to a user. Requires USER_MANAGEMENT permission."
|
||||
)
|
||||
@ApiResponses({
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Role assigned successfully",
|
||||
content = @Content(schema = @Schema(implementation = UserDTO.class))
|
||||
),
|
||||
@ApiResponse(responseCode = "404", description = "User or role not found"),
|
||||
@ApiResponse(responseCode = "403", description = "Missing USER_MANAGEMENT permission"),
|
||||
@ApiResponse(responseCode = "401", description = "Authentication required")
|
||||
})
|
||||
public ResponseEntity<UserDTO> assignRole(
|
||||
@Parameter(description = "User ID", example = "user-uuid")
|
||||
@PathVariable("id") String userId,
|
||||
@Valid @RequestBody AssignRoleRequest request,
|
||||
Authentication authentication
|
||||
) {
|
||||
ActorId actorId = extractActorId(authentication);
|
||||
logger.info("Assigning role {} to user: {} by actor: {}",
|
||||
request.roleName(), userId, actorId.value());
|
||||
|
||||
AssignRoleCommand command = new AssignRoleCommand(userId, request.roleName());
|
||||
Result<UserError, UserDTO> result = assignRole.execute(command, actorId);
|
||||
|
||||
if (result.isFailure()) {
|
||||
throw new DomainErrorException(result.unsafeGetError());
|
||||
}
|
||||
|
||||
logger.info("Role assigned successfully to user: {}", userId);
|
||||
return ResponseEntity.ok(result.unsafeGetValue());
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove role endpoint.
|
||||
*
|
||||
* Removes a role from a user.
|
||||
* Requires ADMIN permission (USER_MANAGEMENT).
|
||||
*
|
||||
* DELETE /api/users/{id}/roles/{roleName}
|
||||
* Authorization: Bearer <access-token>
|
||||
*
|
||||
* Response (204 No Content):
|
||||
* (empty body)
|
||||
*
|
||||
* @param userId User ID
|
||||
* @param roleName Role name to remove
|
||||
* @param authentication Current authentication
|
||||
* @return Empty response
|
||||
*/
|
||||
@DeleteMapping("/{id}/roles/{roleName}")
|
||||
@PreAuthorize("hasAuthority('ROLE_REMOVE')")
|
||||
@Operation(
|
||||
summary = "Remove role (ADMIN only)",
|
||||
description = "Remove a role from a user. Requires USER_MANAGEMENT permission."
|
||||
)
|
||||
@ApiResponses({
|
||||
@ApiResponse(responseCode = "204", description = "Role removed successfully"),
|
||||
@ApiResponse(responseCode = "404", description = "User or role not found"),
|
||||
@ApiResponse(responseCode = "403", description = "Missing USER_MANAGEMENT permission"),
|
||||
@ApiResponse(responseCode = "401", description = "Authentication required")
|
||||
})
|
||||
public ResponseEntity<Void> removeRole(
|
||||
@Parameter(description = "User ID", example = "user-uuid")
|
||||
@PathVariable("id") String userId,
|
||||
@Parameter(description = "Role name", example = "MANAGER")
|
||||
@PathVariable("roleName") RoleName roleName,
|
||||
Authentication authentication
|
||||
) {
|
||||
ActorId actorId = extractActorId(authentication);
|
||||
logger.info("Removing role {} from user: {} by actor: {}",
|
||||
roleName, userId, actorId.value());
|
||||
|
||||
Result<UserError, UserDTO> result = removeRole.execute(userId, roleName, actorId);
|
||||
|
||||
if (result.isFailure()) {
|
||||
throw new DomainErrorException(result.unsafeGetError());
|
||||
}
|
||||
|
||||
logger.info("Role removed successfully from user: {}", userId);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Change password endpoint.
|
||||
*
|
||||
* Changes a user's password.
|
||||
* Requires current password for verification.
|
||||
* Users can change their own password.
|
||||
*
|
||||
* PUT /api/users/{id}/password
|
||||
* Authorization: Bearer <access-token>
|
||||
*
|
||||
* Request Body:
|
||||
* {
|
||||
* "currentPassword": "OldPass123",
|
||||
* "newPassword": "NewSecurePass456"
|
||||
* }
|
||||
*
|
||||
* Response (204 No Content):
|
||||
* (empty body)
|
||||
*
|
||||
* @param userId User ID
|
||||
* @param request Change password request
|
||||
* @param authentication Current authentication
|
||||
* @return Empty response
|
||||
*/
|
||||
@PutMapping("/{id}/password")
|
||||
@Operation(
|
||||
summary = "Change password",
|
||||
description = "Change user password. Requires current password for verification."
|
||||
)
|
||||
@ApiResponses({
|
||||
@ApiResponse(responseCode = "204", description = "Password changed successfully"),
|
||||
@ApiResponse(responseCode = "400", description = "Invalid password"),
|
||||
@ApiResponse(responseCode = "401", description = "Invalid current password or authentication required"),
|
||||
@ApiResponse(responseCode = "404", description = "User not found")
|
||||
})
|
||||
public ResponseEntity<Void> changePassword(
|
||||
@Parameter(description = "User ID", example = "user-uuid")
|
||||
@PathVariable("id") String userId,
|
||||
@Valid @RequestBody ChangePasswordRequest request,
|
||||
Authentication authentication
|
||||
) {
|
||||
ActorId actorId = extractActorId(authentication);
|
||||
logger.info("Changing password for user: {} by actor: {}", userId, actorId.value());
|
||||
|
||||
ChangePasswordCommand command = new ChangePasswordCommand(
|
||||
userId,
|
||||
request.currentPassword(),
|
||||
request.newPassword()
|
||||
);
|
||||
|
||||
Result<UserError, Void> result = changePassword.execute(command, actorId);
|
||||
|
||||
if (result.isFailure()) {
|
||||
throw new DomainErrorException(result.unsafeGetError());
|
||||
}
|
||||
|
||||
logger.info("Password changed successfully for user: {}", userId);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
// ==================== Helper Methods ====================
|
||||
|
||||
/**
|
||||
* Extracts ActorId from Spring Security Authentication.
|
||||
*/
|
||||
private ActorId extractActorId(Authentication authentication) {
|
||||
if (authentication == null || authentication.getName() == null) {
|
||||
throw new IllegalStateException("No authentication found in SecurityContext");
|
||||
}
|
||||
return ActorId.of(authentication.getName());
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom exception to wrap UserError for domain failures.
|
||||
* This exception is caught by GlobalExceptionHandler.
|
||||
*/
|
||||
public static class DomainErrorException extends RuntimeException {
|
||||
private final UserError error;
|
||||
|
||||
public DomainErrorException(UserError error) {
|
||||
super(error.message());
|
||||
this.error = error;
|
||||
}
|
||||
|
||||
public UserError getError() {
|
||||
return error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
package de.effigenix.infrastructure.usermanagement.web.dto;
|
||||
|
||||
import de.effigenix.domain.usermanagement.RoleName;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
/**
|
||||
* Request DTO for assigning a role to a user.
|
||||
*
|
||||
* Used by POST /api/users/{id}/roles endpoint (ADMIN only).
|
||||
*/
|
||||
@Schema(description = "Request to assign a role to a user")
|
||||
public record AssignRoleRequest(
|
||||
@Schema(description = "Role name to assign", example = "MANAGER", required = true)
|
||||
@NotNull(message = "Role name is required")
|
||||
RoleName roleName
|
||||
) {
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
package de.effigenix.infrastructure.usermanagement.web.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
/**
|
||||
* Request DTO for changing user password.
|
||||
*
|
||||
* Used by PUT /api/users/{id}/password endpoint.
|
||||
* Requires current password for verification.
|
||||
*/
|
||||
@Schema(description = "Request to change user password")
|
||||
public record ChangePasswordRequest(
|
||||
@Schema(description = "Current password", example = "OldPass123", required = true)
|
||||
@NotBlank(message = "Current password is required")
|
||||
String currentPassword,
|
||||
|
||||
@Schema(description = "New password (min 8 characters)", example = "NewSecurePass456", required = true)
|
||||
@NotBlank(message = "New password is required")
|
||||
@Size(min = 8, message = "New password must be at least 8 characters")
|
||||
String newPassword
|
||||
) {
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
package de.effigenix.infrastructure.usermanagement.web.dto;
|
||||
|
||||
import de.effigenix.domain.usermanagement.RoleName;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Request DTO for creating a new user.
|
||||
*
|
||||
* Used by POST /api/users endpoint (ADMIN only).
|
||||
*/
|
||||
@Schema(description = "Request to create a new user")
|
||||
public record CreateUserRequest(
|
||||
@Schema(description = "Username (unique)", example = "john.doe", required = true)
|
||||
@NotBlank(message = "Username is required")
|
||||
@Size(min = 3, max = 50, message = "Username must be between 3 and 50 characters")
|
||||
String username,
|
||||
|
||||
@Schema(description = "Email address (unique)", example = "john.doe@example.com", required = true)
|
||||
@NotBlank(message = "Email is required")
|
||||
@Email(message = "Email must be valid")
|
||||
String email,
|
||||
|
||||
@Schema(description = "Password (min 8 characters)", example = "SecurePass123", required = true)
|
||||
@NotBlank(message = "Password is required")
|
||||
@Size(min = 8, message = "Password must be at least 8 characters")
|
||||
String password,
|
||||
|
||||
@Schema(description = "Role names to assign", example = "[\"USER\", \"MANAGER\"]", required = true)
|
||||
@NotNull(message = "Roles are required")
|
||||
Set<RoleName> roleNames,
|
||||
|
||||
@Schema(description = "Branch ID (optional)", example = "BRANCH-001")
|
||||
String branchId
|
||||
) {
|
||||
}
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
package de.effigenix.infrastructure.usermanagement.web.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Standard error response format for all API errors.
|
||||
*
|
||||
* Provides consistent error structure across all endpoints.
|
||||
* Used by GlobalExceptionHandler for all error responses.
|
||||
*/
|
||||
@Schema(description = "Standard error response format")
|
||||
public record ErrorResponse(
|
||||
@Schema(description = "Error code", example = "USER_NOT_FOUND")
|
||||
String code,
|
||||
|
||||
@Schema(description = "Error message", example = "User with ID 'user-123' not found")
|
||||
String message,
|
||||
|
||||
@Schema(description = "HTTP status code", example = "404")
|
||||
int status,
|
||||
|
||||
@Schema(description = "Timestamp when error occurred")
|
||||
LocalDateTime timestamp,
|
||||
|
||||
@Schema(description = "Request path where error occurred", example = "/api/users/user-123")
|
||||
String path,
|
||||
|
||||
@Schema(description = "Validation errors (for 400 Bad Request)")
|
||||
List<ValidationError> validationErrors
|
||||
) {
|
||||
/**
|
||||
* Creates an ErrorResponse from an ApplicationError.
|
||||
*/
|
||||
public static ErrorResponse from(
|
||||
String code,
|
||||
String message,
|
||||
int status,
|
||||
String path
|
||||
) {
|
||||
return new ErrorResponse(
|
||||
code,
|
||||
message,
|
||||
status,
|
||||
LocalDateTime.now(),
|
||||
path,
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an ErrorResponse with validation errors.
|
||||
*/
|
||||
public static ErrorResponse withValidationErrors(
|
||||
String message,
|
||||
int status,
|
||||
String path,
|
||||
List<ValidationError> validationErrors
|
||||
) {
|
||||
return new ErrorResponse(
|
||||
"VALIDATION_ERROR",
|
||||
message,
|
||||
status,
|
||||
LocalDateTime.now(),
|
||||
path,
|
||||
validationErrors
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a single validation error.
|
||||
*/
|
||||
@Schema(description = "Validation error for a specific field")
|
||||
public record ValidationError(
|
||||
@Schema(description = "Field name", example = "username")
|
||||
String field,
|
||||
|
||||
@Schema(description = "Error message", example = "Username is required")
|
||||
String message
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
package de.effigenix.infrastructure.usermanagement.web.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
/**
|
||||
* Request DTO for user login.
|
||||
*
|
||||
* Used by POST /api/auth/login endpoint.
|
||||
* Contains username and password for authentication.
|
||||
*/
|
||||
@Schema(description = "Login request with username and password")
|
||||
public record LoginRequest(
|
||||
@Schema(description = "Username", example = "admin", required = true)
|
||||
@NotBlank(message = "Username is required")
|
||||
String username,
|
||||
|
||||
@Schema(description = "Password", example = "admin123", required = true)
|
||||
@NotBlank(message = "Password is required")
|
||||
String password
|
||||
) {
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
package de.effigenix.infrastructure.usermanagement.web.dto;
|
||||
|
||||
import de.effigenix.application.usermanagement.dto.SessionToken;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* Response DTO for successful login.
|
||||
*
|
||||
* Contains JWT access token and refresh token.
|
||||
* Client should store the access token and send it in Authorization header
|
||||
* for subsequent requests.
|
||||
*/
|
||||
@Schema(description = "Login response with JWT tokens")
|
||||
public record LoginResponse(
|
||||
@Schema(description = "JWT access token", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...")
|
||||
String accessToken,
|
||||
|
||||
@Schema(description = "Token type", example = "Bearer")
|
||||
String tokenType,
|
||||
|
||||
@Schema(description = "Token expiration time in seconds", example = "3600")
|
||||
long expiresIn,
|
||||
|
||||
@Schema(description = "Token expiration timestamp")
|
||||
LocalDateTime expiresAt,
|
||||
|
||||
@Schema(description = "Refresh token for obtaining new access token")
|
||||
String refreshToken
|
||||
) {
|
||||
/**
|
||||
* Creates a LoginResponse from a SessionToken.
|
||||
*/
|
||||
public static LoginResponse from(SessionToken token) {
|
||||
return new LoginResponse(
|
||||
token.accessToken(),
|
||||
token.tokenType(),
|
||||
token.expiresIn(),
|
||||
token.expiresAt(),
|
||||
token.refreshToken()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
package de.effigenix.infrastructure.usermanagement.web.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
/**
|
||||
* Request DTO for refreshing access token.
|
||||
*
|
||||
* Used by POST /api/auth/refresh endpoint.
|
||||
*/
|
||||
@Schema(description = "Refresh token request")
|
||||
public record RefreshTokenRequest(
|
||||
@Schema(description = "Refresh token", required = true)
|
||||
@NotBlank(message = "Refresh token is required")
|
||||
String refreshToken
|
||||
) {
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
package de.effigenix.infrastructure.usermanagement.web.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.Email;
|
||||
|
||||
/**
|
||||
* Request DTO for updating user details.
|
||||
*
|
||||
* Used by PUT /api/users/{id} endpoint.
|
||||
* All fields are optional - only provided fields will be updated.
|
||||
*/
|
||||
@Schema(description = "Request to update user details")
|
||||
public record UpdateUserRequest(
|
||||
@Schema(description = "New email address", example = "newemail@example.com")
|
||||
@Email(message = "Email must be valid")
|
||||
String email,
|
||||
|
||||
@Schema(description = "New branch ID", example = "BRANCH-002")
|
||||
String branchId
|
||||
) {
|
||||
}
|
||||
|
|
@ -0,0 +1,260 @@
|
|||
package de.effigenix.infrastructure.usermanagement.web.exception;
|
||||
|
||||
import de.effigenix.domain.usermanagement.UserError;
|
||||
import de.effigenix.infrastructure.usermanagement.web.controller.AuthController;
|
||||
import de.effigenix.infrastructure.usermanagement.web.controller.UserController;
|
||||
import de.effigenix.infrastructure.usermanagement.web.dto.ErrorResponse;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.security.core.AuthenticationException;
|
||||
import org.springframework.validation.FieldError;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Global Exception Handler for REST API.
|
||||
*
|
||||
* Provides centralized exception handling for all REST controllers.
|
||||
* Converts exceptions to consistent error responses with appropriate HTTP status codes.
|
||||
*
|
||||
* Error Handling Strategy:
|
||||
* - Domain errors (UserError) → HTTP status from ApplicationError.httpStatus()
|
||||
* - Validation errors → 400 Bad Request
|
||||
* - Authentication errors → 401 Unauthorized
|
||||
* - Authorization errors → 403 Forbidden
|
||||
* - Unexpected errors → 500 Internal Server Error
|
||||
*
|
||||
* Infrastructure Layer → Spring Web Exception Handling
|
||||
*/
|
||||
@RestControllerAdvice
|
||||
public class GlobalExceptionHandler {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class);
|
||||
|
||||
/**
|
||||
* Handles AuthenticationFailedException (wraps UserError from auth operations).
|
||||
*
|
||||
* @param ex Authentication failed exception
|
||||
* @param request HTTP request
|
||||
* @return Error response with appropriate status code
|
||||
*/
|
||||
@ExceptionHandler(AuthController.AuthenticationFailedException.class)
|
||||
public ResponseEntity<ErrorResponse> handleAuthenticationFailed(
|
||||
AuthController.AuthenticationFailedException ex,
|
||||
HttpServletRequest request
|
||||
) {
|
||||
UserError error = ex.getError();
|
||||
int status = UserErrorHttpStatusMapper.toHttpStatus(error);
|
||||
logger.warn("Authentication failed: {} - {}", error.code(), error.message());
|
||||
|
||||
ErrorResponse errorResponse = ErrorResponse.from(
|
||||
error.code(),
|
||||
error.message(),
|
||||
status,
|
||||
request.getRequestURI()
|
||||
);
|
||||
|
||||
return ResponseEntity.status(status).body(errorResponse);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles DomainErrorException (wraps UserError from user management operations).
|
||||
*
|
||||
* @param ex Domain error exception
|
||||
* @param request HTTP request
|
||||
* @return Error response with appropriate status code
|
||||
*/
|
||||
@ExceptionHandler(UserController.DomainErrorException.class)
|
||||
public ResponseEntity<ErrorResponse> handleDomainError(
|
||||
UserController.DomainErrorException ex,
|
||||
HttpServletRequest request
|
||||
) {
|
||||
UserError error = ex.getError();
|
||||
int status = UserErrorHttpStatusMapper.toHttpStatus(error);
|
||||
logger.warn("Domain error: {} - {}", error.code(), error.message());
|
||||
|
||||
ErrorResponse errorResponse = ErrorResponse.from(
|
||||
error.code(),
|
||||
error.message(),
|
||||
status,
|
||||
request.getRequestURI()
|
||||
);
|
||||
|
||||
return ResponseEntity.status(status).body(errorResponse);
|
||||
}
|
||||
|
||||
// Note: UserError and ApplicationError are interfaces, not Throwable
|
||||
// They are wrapped in RuntimeException subclasses (AuthenticationFailedException, DomainErrorException)
|
||||
// which are then caught by the handlers above
|
||||
|
||||
/**
|
||||
* Handles validation errors from @Valid annotations.
|
||||
*
|
||||
* Returns 400 Bad Request with list of validation errors.
|
||||
* Example:
|
||||
* - Username is required
|
||||
* - Email must be valid
|
||||
* - Password must be at least 8 characters
|
||||
*
|
||||
* @param ex Validation exception
|
||||
* @param request HTTP request
|
||||
* @return Error response with validation errors
|
||||
*/
|
||||
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||
public ResponseEntity<ErrorResponse> handleValidationError(
|
||||
MethodArgumentNotValidException ex,
|
||||
HttpServletRequest request
|
||||
) {
|
||||
logger.warn("Validation error: {}", ex.getMessage());
|
||||
|
||||
List<ErrorResponse.ValidationError> validationErrors = ex.getBindingResult()
|
||||
.getAllErrors()
|
||||
.stream()
|
||||
.map(error -> {
|
||||
String fieldName = error instanceof FieldError fieldError
|
||||
? fieldError.getField()
|
||||
: error.getObjectName();
|
||||
String message = error.getDefaultMessage();
|
||||
return new ErrorResponse.ValidationError(fieldName, message);
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
|
||||
ErrorResponse errorResponse = ErrorResponse.withValidationErrors(
|
||||
"Validation failed",
|
||||
HttpStatus.BAD_REQUEST.value(),
|
||||
request.getRequestURI(),
|
||||
validationErrors
|
||||
);
|
||||
|
||||
return ResponseEntity
|
||||
.status(HttpStatus.BAD_REQUEST)
|
||||
.body(errorResponse);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles authentication errors (e.g., invalid JWT token).
|
||||
*
|
||||
* Returns 401 Unauthorized.
|
||||
* This is typically caught by SecurityConfig's authenticationEntryPoint,
|
||||
* but included here for completeness.
|
||||
*
|
||||
* @param ex Authentication exception
|
||||
* @param request HTTP request
|
||||
* @return Error response with 401 status
|
||||
*/
|
||||
@ExceptionHandler(AuthenticationException.class)
|
||||
public ResponseEntity<ErrorResponse> handleAuthenticationError(
|
||||
AuthenticationException ex,
|
||||
HttpServletRequest request
|
||||
) {
|
||||
logger.warn("Authentication error: {}", ex.getMessage());
|
||||
|
||||
ErrorResponse errorResponse = ErrorResponse.from(
|
||||
"AUTHENTICATION_FAILED",
|
||||
"Authentication failed: " + ex.getMessage(),
|
||||
HttpStatus.UNAUTHORIZED.value(),
|
||||
request.getRequestURI()
|
||||
);
|
||||
|
||||
return ResponseEntity
|
||||
.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(errorResponse);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles authorization errors (missing permissions).
|
||||
*
|
||||
* Returns 403 Forbidden.
|
||||
* Triggered when user lacks required permission for an action.
|
||||
*
|
||||
* @param ex Access denied exception
|
||||
* @param request HTTP request
|
||||
* @return Error response with 403 status
|
||||
*/
|
||||
@ExceptionHandler(AccessDeniedException.class)
|
||||
public ResponseEntity<ErrorResponse> handleAccessDeniedError(
|
||||
AccessDeniedException ex,
|
||||
HttpServletRequest request
|
||||
) {
|
||||
logger.warn("Access denied: {}", ex.getMessage());
|
||||
|
||||
ErrorResponse errorResponse = ErrorResponse.from(
|
||||
"ACCESS_DENIED",
|
||||
"Access denied: " + ex.getMessage(),
|
||||
HttpStatus.FORBIDDEN.value(),
|
||||
request.getRequestURI()
|
||||
);
|
||||
|
||||
return ResponseEntity
|
||||
.status(HttpStatus.FORBIDDEN)
|
||||
.body(errorResponse);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles illegal arguments (e.g., invalid UUID format).
|
||||
*
|
||||
* Returns 400 Bad Request.
|
||||
*
|
||||
* @param ex Illegal argument exception
|
||||
* @param request HTTP request
|
||||
* @return Error response with 400 status
|
||||
*/
|
||||
@ExceptionHandler(IllegalArgumentException.class)
|
||||
public ResponseEntity<ErrorResponse> handleIllegalArgumentError(
|
||||
IllegalArgumentException ex,
|
||||
HttpServletRequest request
|
||||
) {
|
||||
logger.warn("Invalid argument: {}", ex.getMessage());
|
||||
|
||||
ErrorResponse errorResponse = ErrorResponse.from(
|
||||
"INVALID_ARGUMENT",
|
||||
ex.getMessage(),
|
||||
HttpStatus.BAD_REQUEST.value(),
|
||||
request.getRequestURI()
|
||||
);
|
||||
|
||||
return ResponseEntity
|
||||
.status(HttpStatus.BAD_REQUEST)
|
||||
.body(errorResponse);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles unexpected runtime errors.
|
||||
*
|
||||
* Returns 500 Internal Server Error.
|
||||
* Logs full stack trace for debugging.
|
||||
*
|
||||
* IMPORTANT: Do not expose internal error details to clients in production!
|
||||
*
|
||||
* @param ex Runtime exception
|
||||
* @param request HTTP request
|
||||
* @return Error response with 500 status
|
||||
*/
|
||||
@ExceptionHandler(RuntimeException.class)
|
||||
public ResponseEntity<ErrorResponse> handleRuntimeError(
|
||||
RuntimeException ex,
|
||||
HttpServletRequest request
|
||||
) {
|
||||
logger.error("Unexpected error: {}", ex.getMessage(), ex);
|
||||
|
||||
ErrorResponse errorResponse = ErrorResponse.from(
|
||||
"INTERNAL_ERROR",
|
||||
"An unexpected error occurred. Please contact support.",
|
||||
HttpStatus.INTERNAL_SERVER_ERROR.value(),
|
||||
request.getRequestURI()
|
||||
);
|
||||
|
||||
return ResponseEntity
|
||||
.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(errorResponse);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
package de.effigenix.infrastructure.usermanagement.web.exception;
|
||||
|
||||
import de.effigenix.domain.usermanagement.UserError;
|
||||
|
||||
public final class UserErrorHttpStatusMapper {
|
||||
|
||||
private UserErrorHttpStatusMapper() {}
|
||||
|
||||
public static int toHttpStatus(UserError error) {
|
||||
return switch (error) {
|
||||
case UserError.UsernameAlreadyExists e -> 409;
|
||||
case UserError.EmailAlreadyExists e -> 409;
|
||||
case UserError.UserNotFound e -> 404;
|
||||
case UserError.InvalidCredentials e -> 401;
|
||||
case UserError.UserLocked e -> 401;
|
||||
case UserError.UserInactive e -> 401;
|
||||
case UserError.RoleNotFound e -> 404;
|
||||
case UserError.InvalidPassword e -> 400;
|
||||
case UserError.Unauthorized e -> 403;
|
||||
case UserError.InvalidEmail e -> 400;
|
||||
case UserError.InvalidUsername e -> 400;
|
||||
case UserError.NullPasswordHash e -> 400;
|
||||
case UserError.NullRole e -> 400;
|
||||
case UserError.RepositoryFailure e -> 500;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
package de.effigenix.infrastructure.web.config;
|
||||
|
||||
import io.swagger.v3.oas.annotations.OpenAPIDefinition;
|
||||
import io.swagger.v3.oas.annotations.enums.SecuritySchemeType;
|
||||
import io.swagger.v3.oas.annotations.info.Contact;
|
||||
import io.swagger.v3.oas.annotations.info.Info;
|
||||
import io.swagger.v3.oas.annotations.info.License;
|
||||
import io.swagger.v3.oas.annotations.security.SecurityScheme;
|
||||
import io.swagger.v3.oas.annotations.servers.Server;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* OpenAPI/Swagger Configuration.
|
||||
*
|
||||
* Provides API documentation via Swagger UI.
|
||||
* Accessible at: http://localhost:8080/swagger-ui/index.html
|
||||
*
|
||||
* API Documentation:
|
||||
* - OpenAPI 3.0 specification
|
||||
* - Interactive API testing via Swagger UI
|
||||
* - JWT Bearer token authentication
|
||||
*
|
||||
* Security:
|
||||
* - Swagger UI is PUBLIC in development
|
||||
* - IMPORTANT: Restrict access in production!
|
||||
*
|
||||
* Infrastructure Layer → API Documentation
|
||||
*/
|
||||
@Configuration
|
||||
@OpenAPIDefinition(
|
||||
info = @Info(
|
||||
title = "Effigenix Fleischerei ERP API",
|
||||
version = "0.1.0",
|
||||
description = """
|
||||
RESTful API for Effigenix Fleischerei ERP System.
|
||||
|
||||
## Authentication
|
||||
|
||||
All endpoints (except /api/auth/login and /api/auth/refresh) require JWT authentication.
|
||||
|
||||
1. Login via POST /api/auth/login with username and password
|
||||
2. Copy the returned access token
|
||||
3. Click "Authorize" button (top right)
|
||||
4. Enter: Bearer <access-token>
|
||||
5. Click "Authorize"
|
||||
|
||||
## User Management
|
||||
|
||||
- **Authentication**: Login, logout, refresh token
|
||||
- **User Management**: Create, update, list users (ADMIN only)
|
||||
- **Role Management**: Assign roles, lock/unlock users (ADMIN only)
|
||||
- **Password Management**: Change password (requires current password)
|
||||
|
||||
## Error Handling
|
||||
|
||||
All errors return a consistent error response format:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "USER_NOT_FOUND",
|
||||
"message": "User with ID 'user-123' not found",
|
||||
"status": 404,
|
||||
"timestamp": "2026-02-17T12:00:00",
|
||||
"path": "/api/users/user-123",
|
||||
"validationErrors": null
|
||||
}
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
Built with:
|
||||
- Domain-Driven Design (DDD)
|
||||
- Clean Architecture (Hexagonal Architecture)
|
||||
- Spring Boot 3.2
|
||||
- Java 21
|
||||
- PostgreSQL
|
||||
""",
|
||||
contact = @Contact(
|
||||
name = "Effigenix Development Team",
|
||||
email = "dev@effigenix.com",
|
||||
url = "https://effigenix.com"
|
||||
),
|
||||
license = @License(
|
||||
name = "Proprietary",
|
||||
url = "https://effigenix.com/license"
|
||||
)
|
||||
),
|
||||
servers = {
|
||||
@Server(
|
||||
url = "http://localhost:8080",
|
||||
description = "Local Development Server"
|
||||
),
|
||||
@Server(
|
||||
url = "https://api.effigenix.com",
|
||||
description = "Production Server"
|
||||
)
|
||||
}
|
||||
)
|
||||
@SecurityScheme(
|
||||
name = "Bearer Authentication",
|
||||
type = SecuritySchemeType.HTTP,
|
||||
scheme = "bearer",
|
||||
bearerFormat = "JWT",
|
||||
description = """
|
||||
JWT authentication token obtained from POST /api/auth/login.
|
||||
|
||||
Format: Bearer <access-token>
|
||||
|
||||
Example:
|
||||
Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
||||
"""
|
||||
)
|
||||
public class OpenApiConfig {
|
||||
// Configuration is done via annotations
|
||||
// No additional beans needed
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
package de.effigenix.shared.common;
|
||||
|
||||
/**
|
||||
* Base interface for all application errors.
|
||||
* Used with Result type for functional error handling.
|
||||
*/
|
||||
public interface ApplicationError {
|
||||
/**
|
||||
* Error code for categorization.
|
||||
*/
|
||||
String code();
|
||||
|
||||
/**
|
||||
* Human-readable error message.
|
||||
*/
|
||||
String message();
|
||||
}
|
||||
206
backend/src/main/java/de/effigenix/shared/common/Result.java
Normal file
206
backend/src/main/java/de/effigenix/shared/common/Result.java
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
package de.effigenix.shared.common;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
|
||||
/**
|
||||
* Result type for functional error handling.
|
||||
* Represents either a success value or a failure error.
|
||||
*
|
||||
* @param <E> Error type
|
||||
* @param <T> Success value type
|
||||
*/
|
||||
public sealed interface Result<E, T> {
|
||||
|
||||
/**
|
||||
* Creates a successful result.
|
||||
*/
|
||||
static <E, T> Result<E, T> success(T value) {
|
||||
return new Success<>(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a failed result.
|
||||
*/
|
||||
static <E, T> Result<E, T> failure(E error) {
|
||||
return new Failure<>(error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if this is a success result.
|
||||
*/
|
||||
boolean isSuccess();
|
||||
|
||||
/**
|
||||
* Returns true if this is a failure result.
|
||||
*/
|
||||
boolean isFailure();
|
||||
|
||||
/**
|
||||
* Gets the success value, throws if failure.
|
||||
*/
|
||||
T unsafeGetValue();
|
||||
|
||||
/**
|
||||
* Gets the error, throws if success.
|
||||
*/
|
||||
E unsafeGetError();
|
||||
|
||||
/**
|
||||
* Gets the success value as Optional.
|
||||
*/
|
||||
Optional<T> toOptional();
|
||||
|
||||
/**
|
||||
* Maps the success value to another type.
|
||||
*/
|
||||
<U> Result<E, U> map(Function<T, U> mapper);
|
||||
|
||||
/**
|
||||
* Maps the error to another type.
|
||||
*/
|
||||
<F> Result<F, T> mapError(Function<E, F> mapper);
|
||||
|
||||
/**
|
||||
* FlatMaps the success value to another Result.
|
||||
*/
|
||||
<U> Result<E, U> flatMap(Function<T, Result<E, U>> mapper);
|
||||
|
||||
/**
|
||||
* Folds the result into a single value.
|
||||
*/
|
||||
<R> R fold(Function<E, R> onFailure, Function<T, R> onSuccess);
|
||||
|
||||
/**
|
||||
* Executes action if success.
|
||||
*/
|
||||
Result<E, T> onSuccess(Consumer<T> action);
|
||||
|
||||
/**
|
||||
* Executes action if failure.
|
||||
*/
|
||||
Result<E, T> onFailure(Consumer<E> action);
|
||||
|
||||
/**
|
||||
* Success implementation.
|
||||
*/
|
||||
record Success<E, T>(T value) implements Result<E, T> {
|
||||
@Override
|
||||
public boolean isSuccess() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isFailure() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public T unsafeGetValue() {
|
||||
return value;
|
||||
}
|
||||
|
||||
@Override
|
||||
public E unsafeGetError() {
|
||||
throw new IllegalStateException("Cannot get error from Success");
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<T> toOptional() {
|
||||
return Optional.ofNullable(value);
|
||||
}
|
||||
|
||||
@Override
|
||||
public <U> Result<E, U> map(Function<T, U> mapper) {
|
||||
return new Success<>(mapper.apply(value));
|
||||
}
|
||||
|
||||
@Override
|
||||
public <F> Result<F, T> mapError(Function<E, F> mapper) {
|
||||
return new Success<>(value);
|
||||
}
|
||||
|
||||
@Override
|
||||
public <U> Result<E, U> flatMap(Function<T, Result<E, U>> mapper) {
|
||||
return mapper.apply(value);
|
||||
}
|
||||
|
||||
@Override
|
||||
public <R> R fold(Function<E, R> onFailure, Function<T, R> onSuccess) {
|
||||
return onSuccess.apply(value);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Result<E, T> onSuccess(Consumer<T> action) {
|
||||
action.accept(value);
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Result<E, T> onFailure(Consumer<E> action) {
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Failure implementation.
|
||||
*/
|
||||
record Failure<E, T>(E error) implements Result<E, T> {
|
||||
@Override
|
||||
public boolean isSuccess() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isFailure() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public T unsafeGetValue() {
|
||||
throw new IllegalStateException("Cannot get value from Failure: " + error);
|
||||
}
|
||||
|
||||
@Override
|
||||
public E unsafeGetError() {
|
||||
return error;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<T> toOptional() {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public <U> Result<E, U> map(Function<T, U> mapper) {
|
||||
return new Failure<>(error);
|
||||
}
|
||||
|
||||
@Override
|
||||
public <F> Result<F, T> mapError(Function<E, F> mapper) {
|
||||
return new Failure<>(mapper.apply(error));
|
||||
}
|
||||
|
||||
@Override
|
||||
public <U> Result<E, U> flatMap(Function<T, Result<E, U>> mapper) {
|
||||
return new Failure<>(error);
|
||||
}
|
||||
|
||||
@Override
|
||||
public <R> R fold(Function<E, R> onFailure, Function<T, R> onSuccess) {
|
||||
return onFailure.apply(error);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Result<E, T> onSuccess(Consumer<T> action) {
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Result<E, T> onFailure(Consumer<E> action) {
|
||||
action.accept(error);
|
||||
return this;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
package de.effigenix.shared.security;
|
||||
|
||||
/**
|
||||
* Marker interface for all domain actions across Bounded Contexts.
|
||||
*
|
||||
* Each BC defines its own Action enum (e.g., ProductionAction, QualityAction)
|
||||
* that implements this interface.
|
||||
*/
|
||||
public interface Action {
|
||||
// Marker interface - each BC defines its own actions as Enum
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
package de.effigenix.shared.security;
|
||||
|
||||
/**
|
||||
* Value Object representing an Actor (user or service account).
|
||||
* Abstracts from UserId to allow future extensibility (e.g., service accounts, API keys).
|
||||
*
|
||||
* Used for authorization and audit logging across all Bounded Contexts.
|
||||
*/
|
||||
public record ActorId(String value) {
|
||||
|
||||
public ActorId {
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new IllegalArgumentException("ActorId cannot be null or empty");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an ActorId from a string value.
|
||||
*/
|
||||
public static ActorId of(String value) {
|
||||
return new ActorId(value);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
package de.effigenix.shared.security;
|
||||
|
||||
/**
|
||||
* Authorization Port - Domain-facing interface for authorization.
|
||||
*
|
||||
* This is the Anti-Corruption Layer (ACL) between domain logic and User Management.
|
||||
* It allows:
|
||||
* - Type-safe, action-oriented authorization (not role-based!)
|
||||
* - Domain language: BCs use their own Action enums
|
||||
* - Decoupling: BCs don't depend on User Management
|
||||
* - Future Keycloak migration without BC changes
|
||||
* - Framework-agnostic: no dependency on Spring Security or thread-locals
|
||||
*
|
||||
* Implementation 1 (MVP): SpringSecurityAuthorizationAdapter → User Management BC
|
||||
* Implementation 2 (future): KeycloakAuthorizationAdapter → Keycloak
|
||||
*
|
||||
* Usage in Use Cases:
|
||||
* <pre>
|
||||
* // Use Case bekommt ActorId als Parameter vom Controller
|
||||
* if (!authPort.can(actorId, ProductionAction.RECIPE_WRITE)) {
|
||||
* return Result.failure(new Unauthorized());
|
||||
* }
|
||||
*
|
||||
* // Resource-level check (e.g., only own branch)
|
||||
* if (!authPort.can(actorId, QualityAction.TEMPERATURE_LOG_WRITE, branchId)) {
|
||||
* return Result.failure(new Unauthorized());
|
||||
* }
|
||||
* </pre>
|
||||
*/
|
||||
public interface AuthorizationPort {
|
||||
|
||||
/**
|
||||
* Checks if the given actor can execute a domain action.
|
||||
*
|
||||
* @param actor The actor performing the action
|
||||
* @param action Type-safe action from a BC's Action enum
|
||||
* @return true if authorized, false otherwise
|
||||
*/
|
||||
boolean can(ActorId actor, Action action);
|
||||
|
||||
/**
|
||||
* Checks if the given actor can execute a domain action on a specific resource.
|
||||
* For resource-level authorization (e.g., user can only access their own branch).
|
||||
*
|
||||
* @param actor The actor performing the action
|
||||
* @param action Type-safe action from a BC's Action enum
|
||||
* @param resource Resource identifier (e.g., BranchId)
|
||||
* @return true if authorized, false otherwise
|
||||
*/
|
||||
boolean can(ActorId actor, Action action, ResourceId resource);
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
package de.effigenix.shared.security;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Value Object representing a Branch's unique identifier.
|
||||
* Used for multi-branch authorization and filtering.
|
||||
*/
|
||||
public record BranchId(String value) implements ResourceId {
|
||||
|
||||
public BranchId {
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new IllegalArgumentException("BranchId cannot be null or empty");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a new random BranchId.
|
||||
*/
|
||||
public static BranchId generate() {
|
||||
return new BranchId(UUID.randomUUID().toString());
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a BranchId from a string value.
|
||||
*/
|
||||
public static BranchId of(String value) {
|
||||
return new BranchId(value);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
package de.effigenix.shared.security;
|
||||
|
||||
/**
|
||||
* Sealed interface for resource identifiers used in resource-level authorization.
|
||||
*
|
||||
* Example: authPort.can(QualityAction.TEMPERATURE_LOG_WRITE, branchId)
|
||||
*
|
||||
* Each Bounded Context can define its own ResourceId implementations.
|
||||
*/
|
||||
public sealed interface ResourceId permits BranchId {
|
||||
String value();
|
||||
}
|
||||
56
backend/src/main/resources/application.yml
Normal file
56
backend/src/main/resources/application.yml
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
spring:
|
||||
application:
|
||||
name: effigenix-erp
|
||||
|
||||
datasource:
|
||||
url: ${DB_URL:jdbc:postgresql://localhost:5432/effigenix}
|
||||
username: ${DB_USERNAME:effigenix}
|
||||
password: ${DB_PASSWORD:effigenix}
|
||||
driver-class-name: org.postgresql.Driver
|
||||
|
||||
jpa:
|
||||
database-platform: org.hibernate.dialect.PostgreSQLDialect
|
||||
hibernate:
|
||||
ddl-auto: validate # Liquibase handles schema, not Hibernate
|
||||
show-sql: false
|
||||
properties:
|
||||
hibernate:
|
||||
format_sql: true
|
||||
use_sql_comments: true
|
||||
|
||||
liquibase:
|
||||
change-log: classpath:db/changelog/db.changelog-master.xml
|
||||
|
||||
security:
|
||||
user:
|
||||
name: ${SPRING_SECURITY_USER:admin}
|
||||
password: ${SPRING_SECURITY_PASSWORD:admin} # Only for development, will be replaced by JWT
|
||||
|
||||
# JWT Configuration
|
||||
jwt:
|
||||
secret: ${JWT_SECRET:VerySecretKeyForDevelopmentPleaseChangeInProductionMin256Bits}
|
||||
expiration: 28800000 # 8 hours in milliseconds
|
||||
refresh-expiration: 604800000 # 7 days in milliseconds
|
||||
|
||||
# Server Configuration
|
||||
server:
|
||||
port: 8080
|
||||
error:
|
||||
include-message: always
|
||||
include-binding-errors: always
|
||||
|
||||
# Logging
|
||||
logging:
|
||||
level:
|
||||
root: INFO
|
||||
de.effigenix: DEBUG
|
||||
org.springframework.security: DEBUG
|
||||
org.hibernate.SQL: DEBUG
|
||||
|
||||
# API Documentation
|
||||
springdoc:
|
||||
api-docs:
|
||||
path: /api-docs
|
||||
swagger-ui:
|
||||
path: /swagger-ui.html
|
||||
enabled: true
|
||||
|
|
@ -0,0 +1,112 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<databaseChangeLog
|
||||
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">
|
||||
|
||||
<changeSet id="001-create-roles-table" author="effigenix">
|
||||
<createTable tableName="roles">
|
||||
<column name="id" type="VARCHAR(36)">
|
||||
<constraints primaryKey="true" nullable="false"/>
|
||||
</column>
|
||||
<column name="name" type="VARCHAR(50)">
|
||||
<constraints nullable="false" unique="true"/>
|
||||
</column>
|
||||
<column name="description" type="VARCHAR(500)"/>
|
||||
</createTable>
|
||||
<createIndex tableName="roles" indexName="idx_roles_name">
|
||||
<column name="name"/>
|
||||
</createIndex>
|
||||
<sql>
|
||||
ALTER TABLE roles ADD CONSTRAINT chk_role_name CHECK (name IN (
|
||||
'ADMIN', 'PRODUCTION_MANAGER', 'PRODUCTION_WORKER',
|
||||
'QUALITY_MANAGER', 'QUALITY_INSPECTOR', 'PROCUREMENT_MANAGER',
|
||||
'WAREHOUSE_WORKER', 'SALES_MANAGER', 'SALES_STAFF'
|
||||
));
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="001-create-role-permissions-table" author="effigenix">
|
||||
<createTable tableName="role_permissions">
|
||||
<column name="role_id" type="VARCHAR(36)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="permission" type="VARCHAR(100)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
</createTable>
|
||||
<addPrimaryKey tableName="role_permissions" columnNames="role_id, permission"/>
|
||||
<addForeignKeyConstraint baseTableName="role_permissions" baseColumnNames="role_id"
|
||||
referencedTableName="roles" referencedColumnNames="id"
|
||||
constraintName="fk_role_permissions_role" onDelete="CASCADE"/>
|
||||
<createIndex tableName="role_permissions" indexName="idx_role_permissions_role_id">
|
||||
<column name="role_id"/>
|
||||
</createIndex>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="001-create-users-table" author="effigenix">
|
||||
<createTable tableName="users">
|
||||
<column name="id" type="VARCHAR(36)">
|
||||
<constraints primaryKey="true" nullable="false"/>
|
||||
</column>
|
||||
<column name="username" type="VARCHAR(100)">
|
||||
<constraints nullable="false" unique="true"/>
|
||||
</column>
|
||||
<column name="email" type="VARCHAR(255)">
|
||||
<constraints nullable="false" unique="true"/>
|
||||
</column>
|
||||
<column name="password_hash" type="VARCHAR(60)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="branch_id" type="VARCHAR(36)"/>
|
||||
<column name="status" type="VARCHAR(20)" defaultValue="ACTIVE">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="created_at" type="TIMESTAMP" defaultValueComputed="CURRENT_TIMESTAMP">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="last_login" type="TIMESTAMP"/>
|
||||
</createTable>
|
||||
<sql>
|
||||
ALTER TABLE users ADD CONSTRAINT chk_user_status CHECK (status IN ('ACTIVE', 'INACTIVE', 'LOCKED'));
|
||||
</sql>
|
||||
<createIndex tableName="users" indexName="idx_users_username">
|
||||
<column name="username"/>
|
||||
</createIndex>
|
||||
<createIndex tableName="users" indexName="idx_users_email">
|
||||
<column name="email"/>
|
||||
</createIndex>
|
||||
<createIndex tableName="users" indexName="idx_users_branch_id">
|
||||
<column name="branch_id"/>
|
||||
</createIndex>
|
||||
<createIndex tableName="users" indexName="idx_users_status">
|
||||
<column name="status"/>
|
||||
</createIndex>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="001-create-user-roles-table" author="effigenix">
|
||||
<createTable tableName="user_roles">
|
||||
<column name="user_id" type="VARCHAR(36)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="role_id" type="VARCHAR(36)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
</createTable>
|
||||
<addPrimaryKey tableName="user_roles" columnNames="user_id, role_id"/>
|
||||
<addForeignKeyConstraint baseTableName="user_roles" baseColumnNames="user_id"
|
||||
referencedTableName="users" referencedColumnNames="id"
|
||||
constraintName="fk_user_roles_user" onDelete="CASCADE"/>
|
||||
<addForeignKeyConstraint baseTableName="user_roles" baseColumnNames="role_id"
|
||||
referencedTableName="roles" referencedColumnNames="id"
|
||||
constraintName="fk_user_roles_role" onDelete="CASCADE"/>
|
||||
<createIndex tableName="user_roles" indexName="idx_user_roles_user_id">
|
||||
<column name="user_id"/>
|
||||
</createIndex>
|
||||
<createIndex tableName="user_roles" indexName="idx_user_roles_role_id">
|
||||
<column name="role_id"/>
|
||||
</createIndex>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
|
|
@ -0,0 +1,348 @@
|
|||
-- ==================== Seed Data: Roles and Permissions ====================
|
||||
-- Loads the 8 predefined roles with their permissions for the Effigenix ERP system.
|
||||
--
|
||||
-- Roles:
|
||||
-- 1. ADMIN - System Administrator (full access)
|
||||
-- 2. PRODUCTION_MANAGER - Manages production recipes, batches, and orders
|
||||
-- 3. PRODUCTION_WORKER - Executes production tasks
|
||||
-- 4. QUALITY_MANAGER - HACCP compliance and quality assurance
|
||||
-- 5. QUALITY_INSPECTOR - Quality inspections and measurements
|
||||
-- 6. PROCUREMENT_MANAGER - Manages purchasing and suppliers
|
||||
-- 7. WAREHOUSE_WORKER - Manages inventory and stock
|
||||
-- 8. SALES_MANAGER - Manages sales orders and customers
|
||||
-- 9. SALES_STAFF - Creates sales orders
|
||||
--
|
||||
-- Database: PostgreSQL
|
||||
-- Liquibase Changeset: 002
|
||||
-- ==================== ==================== ====================
|
||||
|
||||
-- ==================== 1. ADMIN Role ====================
|
||||
-- System Administrator - full access to all features across all bounded contexts
|
||||
|
||||
INSERT INTO roles (id, name, description)
|
||||
VALUES (
|
||||
'c0a80121-0000-0000-0000-000000000001',
|
||||
'ADMIN',
|
||||
'System Administrator with full access to all features and all bounded contexts'
|
||||
);
|
||||
|
||||
-- ADMIN Permissions: ALL permissions
|
||||
INSERT INTO role_permissions (role_id, permission) VALUES
|
||||
-- Production BC
|
||||
('c0a80121-0000-0000-0000-000000000001', 'RECIPE_READ'),
|
||||
('c0a80121-0000-0000-0000-000000000001', 'RECIPE_WRITE'),
|
||||
('c0a80121-0000-0000-0000-000000000001', 'RECIPE_DELETE'),
|
||||
('c0a80121-0000-0000-0000-000000000001', 'BATCH_READ'),
|
||||
('c0a80121-0000-0000-0000-000000000001', 'BATCH_WRITE'),
|
||||
('c0a80121-0000-0000-0000-000000000001', 'BATCH_COMPLETE'),
|
||||
('c0a80121-0000-0000-0000-000000000001', 'BATCH_DELETE'),
|
||||
('c0a80121-0000-0000-0000-000000000001', 'PRODUCTION_ORDER_READ'),
|
||||
('c0a80121-0000-0000-0000-000000000001', 'PRODUCTION_ORDER_WRITE'),
|
||||
('c0a80121-0000-0000-0000-000000000001', 'PRODUCTION_ORDER_DELETE'),
|
||||
-- Quality BC
|
||||
('c0a80121-0000-0000-0000-000000000001', 'HACCP_READ'),
|
||||
('c0a80121-0000-0000-0000-000000000001', 'HACCP_WRITE'),
|
||||
('c0a80121-0000-0000-0000-000000000001', 'TEMPERATURE_LOG_READ'),
|
||||
('c0a80121-0000-0000-0000-000000000001', 'TEMPERATURE_LOG_WRITE'),
|
||||
('c0a80121-0000-0000-0000-000000000001', 'CLEANING_RECORD_READ'),
|
||||
('c0a80121-0000-0000-0000-000000000001', 'CLEANING_RECORD_WRITE'),
|
||||
('c0a80121-0000-0000-0000-000000000001', 'GOODS_INSPECTION_READ'),
|
||||
('c0a80121-0000-0000-0000-000000000001', 'GOODS_INSPECTION_WRITE'),
|
||||
-- Inventory BC
|
||||
('c0a80121-0000-0000-0000-000000000001', 'STOCK_READ'),
|
||||
('c0a80121-0000-0000-0000-000000000001', 'STOCK_WRITE'),
|
||||
('c0a80121-0000-0000-0000-000000000001', 'STOCK_MOVEMENT_READ'),
|
||||
('c0a80121-0000-0000-0000-000000000001', 'STOCK_MOVEMENT_WRITE'),
|
||||
('c0a80121-0000-0000-0000-000000000001', 'INVENTORY_COUNT_READ'),
|
||||
('c0a80121-0000-0000-0000-000000000001', 'INVENTORY_COUNT_WRITE'),
|
||||
-- Procurement BC
|
||||
('c0a80121-0000-0000-0000-000000000001', 'PURCHASE_ORDER_READ'),
|
||||
('c0a80121-0000-0000-0000-000000000001', 'PURCHASE_ORDER_WRITE'),
|
||||
('c0a80121-0000-0000-0000-000000000001', 'PURCHASE_ORDER_DELETE'),
|
||||
('c0a80121-0000-0000-0000-000000000001', 'GOODS_RECEIPT_READ'),
|
||||
('c0a80121-0000-0000-0000-000000000001', 'GOODS_RECEIPT_WRITE'),
|
||||
('c0a80121-0000-0000-0000-000000000001', 'SUPPLIER_READ'),
|
||||
('c0a80121-0000-0000-0000-000000000001', 'SUPPLIER_WRITE'),
|
||||
('c0a80121-0000-0000-0000-000000000001', 'SUPPLIER_DELETE'),
|
||||
-- Sales BC
|
||||
('c0a80121-0000-0000-0000-000000000001', 'ORDER_READ'),
|
||||
('c0a80121-0000-0000-0000-000000000001', 'ORDER_WRITE'),
|
||||
('c0a80121-0000-0000-0000-000000000001', 'ORDER_DELETE'),
|
||||
('c0a80121-0000-0000-0000-000000000001', 'INVOICE_READ'),
|
||||
('c0a80121-0000-0000-0000-000000000001', 'INVOICE_WRITE'),
|
||||
('c0a80121-0000-0000-0000-000000000001', 'INVOICE_DELETE'),
|
||||
('c0a80121-0000-0000-0000-000000000001', 'CUSTOMER_READ'),
|
||||
('c0a80121-0000-0000-0000-000000000001', 'CUSTOMER_WRITE'),
|
||||
('c0a80121-0000-0000-0000-000000000001', 'CUSTOMER_DELETE'),
|
||||
-- Labeling BC
|
||||
('c0a80121-0000-0000-0000-000000000001', 'LABEL_READ'),
|
||||
('c0a80121-0000-0000-0000-000000000001', 'LABEL_WRITE'),
|
||||
('c0a80121-0000-0000-0000-000000000001', 'LABEL_PRINT'),
|
||||
-- Filiales BC
|
||||
('c0a80121-0000-0000-0000-000000000001', 'BRANCH_READ'),
|
||||
('c0a80121-0000-0000-0000-000000000001', 'BRANCH_WRITE'),
|
||||
('c0a80121-0000-0000-0000-000000000001', 'BRANCH_DELETE'),
|
||||
-- User Management BC
|
||||
('c0a80121-0000-0000-0000-000000000001', 'USER_READ'),
|
||||
('c0a80121-0000-0000-0000-000000000001', 'USER_WRITE'),
|
||||
('c0a80121-0000-0000-0000-000000000001', 'USER_DELETE'),
|
||||
('c0a80121-0000-0000-0000-000000000001', 'USER_LOCK'),
|
||||
('c0a80121-0000-0000-0000-000000000001', 'USER_UNLOCK'),
|
||||
('c0a80121-0000-0000-0000-000000000001', 'ROLE_READ'),
|
||||
('c0a80121-0000-0000-0000-000000000001', 'ROLE_WRITE'),
|
||||
('c0a80121-0000-0000-0000-000000000001', 'ROLE_ASSIGN'),
|
||||
('c0a80121-0000-0000-0000-000000000001', 'ROLE_REMOVE'),
|
||||
-- Reporting BC
|
||||
('c0a80121-0000-0000-0000-000000000001', 'REPORT_READ'),
|
||||
('c0a80121-0000-0000-0000-000000000001', 'REPORT_GENERATE'),
|
||||
-- Notifications BC
|
||||
('c0a80121-0000-0000-0000-000000000001', 'NOTIFICATION_READ'),
|
||||
('c0a80121-0000-0000-0000-000000000001', 'NOTIFICATION_SEND'),
|
||||
-- System
|
||||
('c0a80121-0000-0000-0000-000000000001', 'AUDIT_LOG_READ'),
|
||||
('c0a80121-0000-0000-0000-000000000001', 'SYSTEM_SETTINGS_READ'),
|
||||
('c0a80121-0000-0000-0000-000000000001', 'SYSTEM_SETTINGS_WRITE');
|
||||
|
||||
|
||||
-- ==================== 2. PRODUCTION_MANAGER Role ====================
|
||||
-- Manages recipes, batches, and production orders
|
||||
|
||||
INSERT INTO roles (id, name, description)
|
||||
VALUES (
|
||||
'c0a80121-0000-0000-0000-000000000002',
|
||||
'PRODUCTION_MANAGER',
|
||||
'Manages production recipes, batches, and production orders. Can read stock levels.'
|
||||
);
|
||||
|
||||
-- PRODUCTION_MANAGER Permissions
|
||||
INSERT INTO role_permissions (role_id, permission) VALUES
|
||||
-- Production BC - Full access
|
||||
('c0a80121-0000-0000-0000-000000000002', 'RECIPE_READ'),
|
||||
('c0a80121-0000-0000-0000-000000000002', 'RECIPE_WRITE'),
|
||||
('c0a80121-0000-0000-0000-000000000002', 'RECIPE_DELETE'),
|
||||
('c0a80121-0000-0000-0000-000000000002', 'BATCH_READ'),
|
||||
('c0a80121-0000-0000-0000-000000000002', 'BATCH_WRITE'),
|
||||
('c0a80121-0000-0000-0000-000000000002', 'BATCH_COMPLETE'),
|
||||
('c0a80121-0000-0000-0000-000000000002', 'BATCH_DELETE'),
|
||||
('c0a80121-0000-0000-0000-000000000002', 'PRODUCTION_ORDER_READ'),
|
||||
('c0a80121-0000-0000-0000-000000000002', 'PRODUCTION_ORDER_WRITE'),
|
||||
('c0a80121-0000-0000-0000-000000000002', 'PRODUCTION_ORDER_DELETE'),
|
||||
-- Inventory BC - Read-only access to stock
|
||||
('c0a80121-0000-0000-0000-000000000002', 'STOCK_READ'),
|
||||
('c0a80121-0000-0000-0000-000000000002', 'STOCK_MOVEMENT_READ'),
|
||||
-- Quality BC - Read access to quality records
|
||||
('c0a80121-0000-0000-0000-000000000002', 'HACCP_READ'),
|
||||
('c0a80121-0000-0000-0000-000000000002', 'TEMPERATURE_LOG_READ'),
|
||||
('c0a80121-0000-0000-0000-000000000002', 'CLEANING_RECORD_READ'),
|
||||
-- Reporting
|
||||
('c0a80121-0000-0000-0000-000000000002', 'REPORT_READ'),
|
||||
('c0a80121-0000-0000-0000-000000000002', 'REPORT_GENERATE');
|
||||
|
||||
|
||||
-- ==================== 3. PRODUCTION_WORKER Role ====================
|
||||
-- Executes recipes and creates batches
|
||||
|
||||
INSERT INTO roles (id, name, description)
|
||||
VALUES (
|
||||
'c0a80121-0000-0000-0000-000000000003',
|
||||
'PRODUCTION_WORKER',
|
||||
'Executes production recipes and creates batches. Can complete batches and view production orders.'
|
||||
);
|
||||
|
||||
-- PRODUCTION_WORKER Permissions
|
||||
INSERT INTO role_permissions (role_id, permission) VALUES
|
||||
-- Production BC - Execution permissions
|
||||
('c0a80121-0000-0000-0000-000000000003', 'RECIPE_READ'),
|
||||
('c0a80121-0000-0000-0000-000000000003', 'BATCH_READ'),
|
||||
('c0a80121-0000-0000-0000-000000000003', 'BATCH_WRITE'),
|
||||
('c0a80121-0000-0000-0000-000000000003', 'BATCH_COMPLETE'),
|
||||
('c0a80121-0000-0000-0000-000000000003', 'PRODUCTION_ORDER_READ'),
|
||||
-- Inventory BC - Read-only access to stock
|
||||
('c0a80121-0000-0000-0000-000000000003', 'STOCK_READ'),
|
||||
-- Labeling BC - Print labels
|
||||
('c0a80121-0000-0000-0000-000000000003', 'LABEL_READ'),
|
||||
('c0a80121-0000-0000-0000-000000000003', 'LABEL_PRINT');
|
||||
|
||||
|
||||
-- ==================== 4. QUALITY_MANAGER Role ====================
|
||||
-- HACCP compliance and quality assurance
|
||||
|
||||
INSERT INTO roles (id, name, description)
|
||||
VALUES (
|
||||
'c0a80121-0000-0000-0000-000000000004',
|
||||
'QUALITY_MANAGER',
|
||||
'Manages HACCP compliance, quality assurance, and quality inspections.'
|
||||
);
|
||||
|
||||
-- QUALITY_MANAGER Permissions
|
||||
INSERT INTO role_permissions (role_id, permission) VALUES
|
||||
-- Quality BC - Full access
|
||||
('c0a80121-0000-0000-0000-000000000004', 'HACCP_READ'),
|
||||
('c0a80121-0000-0000-0000-000000000004', 'HACCP_WRITE'),
|
||||
('c0a80121-0000-0000-0000-000000000004', 'TEMPERATURE_LOG_READ'),
|
||||
('c0a80121-0000-0000-0000-000000000004', 'TEMPERATURE_LOG_WRITE'),
|
||||
('c0a80121-0000-0000-0000-000000000004', 'CLEANING_RECORD_READ'),
|
||||
('c0a80121-0000-0000-0000-000000000004', 'CLEANING_RECORD_WRITE'),
|
||||
('c0a80121-0000-0000-0000-000000000004', 'GOODS_INSPECTION_READ'),
|
||||
('c0a80121-0000-0000-0000-000000000004', 'GOODS_INSPECTION_WRITE'),
|
||||
-- Production BC - Read access to batches and recipes
|
||||
('c0a80121-0000-0000-0000-000000000004', 'RECIPE_READ'),
|
||||
('c0a80121-0000-0000-0000-000000000004', 'BATCH_READ'),
|
||||
-- Inventory BC - Read access to stock
|
||||
('c0a80121-0000-0000-0000-000000000004', 'STOCK_READ'),
|
||||
-- Procurement BC - Read access to goods receipts
|
||||
('c0a80121-0000-0000-0000-000000000004', 'GOODS_RECEIPT_READ'),
|
||||
-- Reporting
|
||||
('c0a80121-0000-0000-0000-000000000004', 'REPORT_READ'),
|
||||
('c0a80121-0000-0000-0000-000000000004', 'REPORT_GENERATE');
|
||||
|
||||
|
||||
-- ==================== 5. QUALITY_INSPECTOR Role ====================
|
||||
-- Quality inspections and measurements
|
||||
|
||||
INSERT INTO roles (id, name, description)
|
||||
VALUES (
|
||||
'c0a80121-0000-0000-0000-000000000005',
|
||||
'QUALITY_INSPECTOR',
|
||||
'Performs quality inspections, records temperature logs and cleaning records.'
|
||||
);
|
||||
|
||||
-- QUALITY_INSPECTOR Permissions
|
||||
INSERT INTO role_permissions (role_id, permission) VALUES
|
||||
-- Quality BC - Inspection and logging permissions
|
||||
('c0a80121-0000-0000-0000-000000000005', 'TEMPERATURE_LOG_READ'),
|
||||
('c0a80121-0000-0000-0000-000000000005', 'TEMPERATURE_LOG_WRITE'),
|
||||
('c0a80121-0000-0000-0000-000000000005', 'CLEANING_RECORD_READ'),
|
||||
('c0a80121-0000-0000-0000-000000000005', 'GOODS_INSPECTION_READ'),
|
||||
('c0a80121-0000-0000-0000-000000000005', 'GOODS_INSPECTION_WRITE'),
|
||||
('c0a80121-0000-0000-0000-000000000005', 'HACCP_READ'),
|
||||
-- Production BC - Read access to batches
|
||||
('c0a80121-0000-0000-0000-000000000005', 'BATCH_READ'),
|
||||
-- Inventory BC - Read access to stock
|
||||
('c0a80121-0000-0000-0000-000000000005', 'STOCK_READ');
|
||||
|
||||
|
||||
-- ==================== 6. PROCUREMENT_MANAGER Role ====================
|
||||
-- Manages purchasing and suppliers
|
||||
|
||||
INSERT INTO roles (id, name, description)
|
||||
VALUES (
|
||||
'c0a80121-0000-0000-0000-000000000006',
|
||||
'PROCUREMENT_MANAGER',
|
||||
'Manages purchase orders, goods receipts, and supplier relationships.'
|
||||
);
|
||||
|
||||
-- PROCUREMENT_MANAGER Permissions
|
||||
INSERT INTO role_permissions (role_id, permission) VALUES
|
||||
-- Procurement BC - Full access
|
||||
('c0a80121-0000-0000-0000-000000000006', 'PURCHASE_ORDER_READ'),
|
||||
('c0a80121-0000-0000-0000-000000000006', 'PURCHASE_ORDER_WRITE'),
|
||||
('c0a80121-0000-0000-0000-000000000006', 'PURCHASE_ORDER_DELETE'),
|
||||
('c0a80121-0000-0000-0000-000000000006', 'GOODS_RECEIPT_READ'),
|
||||
('c0a80121-0000-0000-0000-000000000006', 'GOODS_RECEIPT_WRITE'),
|
||||
('c0a80121-0000-0000-0000-000000000006', 'SUPPLIER_READ'),
|
||||
('c0a80121-0000-0000-0000-000000000006', 'SUPPLIER_WRITE'),
|
||||
('c0a80121-0000-0000-0000-000000000006', 'SUPPLIER_DELETE'),
|
||||
-- Inventory BC - Read access to stock
|
||||
('c0a80121-0000-0000-0000-000000000006', 'STOCK_READ'),
|
||||
('c0a80121-0000-0000-0000-000000000006', 'STOCK_MOVEMENT_READ'),
|
||||
-- Quality BC - Access to goods inspections
|
||||
('c0a80121-0000-0000-0000-000000000006', 'GOODS_INSPECTION_READ'),
|
||||
-- Reporting
|
||||
('c0a80121-0000-0000-0000-000000000006', 'REPORT_READ'),
|
||||
('c0a80121-0000-0000-0000-000000000006', 'REPORT_GENERATE');
|
||||
|
||||
|
||||
-- ==================== 7. WAREHOUSE_WORKER Role ====================
|
||||
-- Manages inventory and stock
|
||||
|
||||
INSERT INTO roles (id, name, description)
|
||||
VALUES (
|
||||
'c0a80121-0000-0000-0000-000000000007',
|
||||
'WAREHOUSE_WORKER',
|
||||
'Manages inventory, stock movements, and inventory counts.'
|
||||
);
|
||||
|
||||
-- WAREHOUSE_WORKER Permissions
|
||||
INSERT INTO role_permissions (role_id, permission) VALUES
|
||||
-- Inventory BC - Full access
|
||||
('c0a80121-0000-0000-0000-000000000007', 'STOCK_READ'),
|
||||
('c0a80121-0000-0000-0000-000000000007', 'STOCK_WRITE'),
|
||||
('c0a80121-0000-0000-0000-000000000007', 'STOCK_MOVEMENT_READ'),
|
||||
('c0a80121-0000-0000-0000-000000000007', 'STOCK_MOVEMENT_WRITE'),
|
||||
('c0a80121-0000-0000-0000-000000000007', 'INVENTORY_COUNT_READ'),
|
||||
('c0a80121-0000-0000-0000-000000000007', 'INVENTORY_COUNT_WRITE'),
|
||||
-- Procurement BC - Goods receipt access
|
||||
('c0a80121-0000-0000-0000-000000000007', 'GOODS_RECEIPT_READ'),
|
||||
('c0a80121-0000-0000-0000-000000000007', 'GOODS_RECEIPT_WRITE'),
|
||||
-- Sales BC - Read access to orders
|
||||
('c0a80121-0000-0000-0000-000000000007', 'ORDER_READ'),
|
||||
-- Labeling BC - Print labels
|
||||
('c0a80121-0000-0000-0000-000000000007', 'LABEL_READ'),
|
||||
('c0a80121-0000-0000-0000-000000000007', 'LABEL_PRINT');
|
||||
|
||||
|
||||
-- ==================== 8. SALES_MANAGER Role ====================
|
||||
-- Manages sales orders and customers
|
||||
|
||||
INSERT INTO roles (id, name, description)
|
||||
VALUES (
|
||||
'c0a80121-0000-0000-0000-000000000008',
|
||||
'SALES_MANAGER',
|
||||
'Manages sales orders, invoices, and customer relationships.'
|
||||
);
|
||||
|
||||
-- SALES_MANAGER Permissions
|
||||
INSERT INTO role_permissions (role_id, permission) VALUES
|
||||
-- Sales BC - Full access
|
||||
('c0a80121-0000-0000-0000-000000000008', 'ORDER_READ'),
|
||||
('c0a80121-0000-0000-0000-000000000008', 'ORDER_WRITE'),
|
||||
('c0a80121-0000-0000-0000-000000000008', 'ORDER_DELETE'),
|
||||
('c0a80121-0000-0000-0000-000000000008', 'INVOICE_READ'),
|
||||
('c0a80121-0000-0000-0000-000000000008', 'INVOICE_WRITE'),
|
||||
('c0a80121-0000-0000-0000-000000000008', 'INVOICE_DELETE'),
|
||||
('c0a80121-0000-0000-0000-000000000008', 'CUSTOMER_READ'),
|
||||
('c0a80121-0000-0000-0000-000000000008', 'CUSTOMER_WRITE'),
|
||||
('c0a80121-0000-0000-0000-000000000008', 'CUSTOMER_DELETE'),
|
||||
-- Inventory BC - Read access to stock
|
||||
('c0a80121-0000-0000-0000-000000000008', 'STOCK_READ'),
|
||||
('c0a80121-0000-0000-0000-000000000008', 'STOCK_MOVEMENT_READ'),
|
||||
-- Production BC - Read access to batches
|
||||
('c0a80121-0000-0000-0000-000000000008', 'BATCH_READ'),
|
||||
-- Reporting
|
||||
('c0a80121-0000-0000-0000-000000000008', 'REPORT_READ'),
|
||||
('c0a80121-0000-0000-0000-000000000008', 'REPORT_GENERATE');
|
||||
|
||||
|
||||
-- ==================== 9. SALES_STAFF Role ====================
|
||||
-- Creates sales orders and views customers
|
||||
|
||||
INSERT INTO roles (id, name, description)
|
||||
VALUES (
|
||||
'c0a80121-0000-0000-0000-000000000009',
|
||||
'SALES_STAFF',
|
||||
'Creates and manages sales orders, views customer information and stock levels.'
|
||||
);
|
||||
|
||||
-- SALES_STAFF Permissions
|
||||
INSERT INTO role_permissions (role_id, permission) VALUES
|
||||
-- Sales BC - Order management
|
||||
('c0a80121-0000-0000-0000-000000000009', 'ORDER_READ'),
|
||||
('c0a80121-0000-0000-0000-000000000009', 'ORDER_WRITE'),
|
||||
('c0a80121-0000-0000-0000-000000000009', 'CUSTOMER_READ'),
|
||||
-- Inventory BC - Read access to stock
|
||||
('c0a80121-0000-0000-0000-000000000009', 'STOCK_READ'),
|
||||
-- Production BC - Read access to batches
|
||||
('c0a80121-0000-0000-0000-000000000009', 'BATCH_READ');
|
||||
|
||||
|
||||
-- ==================== Verification Queries ====================
|
||||
-- Run these queries to verify the seed data was loaded correctly:
|
||||
--
|
||||
-- SELECT COUNT(*) FROM roles; -- Should be 9
|
||||
-- SELECT COUNT(*) FROM role_permissions; -- Should be ~200+
|
||||
-- SELECT name, COUNT(*) as permission_count
|
||||
-- FROM roles r
|
||||
-- JOIN role_permissions rp ON r.id = rp.role_id
|
||||
-- GROUP BY name
|
||||
-- ORDER BY name;
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<databaseChangeLog
|
||||
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">
|
||||
|
||||
<changeSet id="002-seed-roles-and-permissions" author="effigenix">
|
||||
<sqlFile path="db/changelog/changes/002-seed-roles-and-permissions.sql"
|
||||
splitStatements="true"
|
||||
stripComments="true"/>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<databaseChangeLog
|
||||
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">
|
||||
|
||||
<changeSet id="003-create-audit-logs-table" author="effigenix">
|
||||
<createTable tableName="audit_logs">
|
||||
<column name="id" type="VARCHAR(36)">
|
||||
<constraints primaryKey="true" nullable="false"/>
|
||||
</column>
|
||||
<column name="event" type="VARCHAR(100)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="entity_id" type="VARCHAR(36)"/>
|
||||
<column name="performed_by" type="VARCHAR(36)"/>
|
||||
<column name="details" type="VARCHAR(2000)"/>
|
||||
<column name="timestamp" type="TIMESTAMP">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="ip_address" type="VARCHAR(45)"/>
|
||||
<column name="user_agent" type="VARCHAR(500)"/>
|
||||
<column name="created_at" type="TIMESTAMP" defaultValueComputed="CURRENT_TIMESTAMP">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
</createTable>
|
||||
<createIndex tableName="audit_logs" indexName="idx_audit_event">
|
||||
<column name="event"/>
|
||||
</createIndex>
|
||||
<createIndex tableName="audit_logs" indexName="idx_audit_actor">
|
||||
<column name="performed_by"/>
|
||||
</createIndex>
|
||||
<createIndex tableName="audit_logs" indexName="idx_audit_timestamp">
|
||||
<column name="timestamp"/>
|
||||
</createIndex>
|
||||
<createIndex tableName="audit_logs" indexName="idx_audit_entity">
|
||||
<column name="entity_id"/>
|
||||
</createIndex>
|
||||
<createIndex tableName="audit_logs" indexName="idx_audit_created_at">
|
||||
<column name="created_at"/>
|
||||
</createIndex>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
-- Seed Admin User for initial system access
|
||||
-- Username: admin
|
||||
-- Password: admin123
|
||||
-- BCrypt hash with strength 12
|
||||
|
||||
-- Insert Admin User
|
||||
INSERT INTO users (id, username, email, password_hash, branch_id, status, created_at, last_login)
|
||||
VALUES (
|
||||
'00000000-0000-0000-0000-000000000001', -- Fixed UUID for admin
|
||||
'admin',
|
||||
'admin@effigenix.com',
|
||||
'$2a$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewY5GyYKKHFw3zqm', -- BCrypt hash for "admin123"
|
||||
NULL, -- No branch = global access
|
||||
'ACTIVE',
|
||||
CURRENT_TIMESTAMP,
|
||||
NULL
|
||||
);
|
||||
|
||||
-- Assign ADMIN role to admin user
|
||||
INSERT INTO user_roles (user_id, role_id)
|
||||
SELECT '00000000-0000-0000-0000-000000000001', id
|
||||
FROM roles
|
||||
WHERE name = 'ADMIN';
|
||||
|
||||
-- Add comment
|
||||
COMMENT ON TABLE users IS 'Default admin user: username=admin, password=admin123 (CHANGE IN PRODUCTION!)';
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<databaseChangeLog
|
||||
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">
|
||||
|
||||
<changeSet id="004-seed-admin-user" author="effigenix">
|
||||
<sqlFile path="db/changelog/changes/004-seed-admin-user.sql"
|
||||
splitStatements="true"
|
||||
stripComments="true"/>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<databaseChangeLog
|
||||
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">
|
||||
|
||||
<include file="db/changelog/changes/001-create-user-management-schema.xml"/>
|
||||
<include file="db/changelog/changes/002-seed-roles-and-permissions.xml"/>
|
||||
<include file="db/changelog/changes/003-create-audit-logs-table.xml"/>
|
||||
<include file="db/changelog/changes/004-seed-admin-user.xml"/>
|
||||
|
||||
</databaseChangeLog>
|
||||
Loading…
Add table
Add a link
Reference in a new issue