From aff10704e0b75c579de2da2efabb57a56d97c18c Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Mon, 30 Mar 2026 10:23:59 +0200 Subject: [PATCH] feat: add user entity, registration, and RBAC model Co-Authored-By: Claude Opus 4.6 (1M context) --- .../cameleer/saas/auth/AuthService.java | 55 ++++++++ .../cameleer/saas/auth/JwtService.java | 19 +++ .../cameleer/saas/auth/PermissionEntity.java | 45 +++++++ .../cameleer/saas/auth/RoleEntity.java | 94 ++++++++++++++ .../cameleer/saas/auth/RoleRepository.java | 13 ++ .../cameleer/saas/auth/UserEntity.java | 122 ++++++++++++++++++ .../cameleer/saas/auth/UserRepository.java | 15 +++ .../cameleer/saas/auth/dto/AuthResponse.java | 8 ++ .../saas/auth/dto/RegisterRequest.java | 12 ++ .../cameleer/saas/auth/AuthServiceTest.java | 98 ++++++++++++++ 10 files changed, 481 insertions(+) create mode 100644 src/main/java/net/siegeln/cameleer/saas/auth/AuthService.java create mode 100644 src/main/java/net/siegeln/cameleer/saas/auth/JwtService.java create mode 100644 src/main/java/net/siegeln/cameleer/saas/auth/PermissionEntity.java create mode 100644 src/main/java/net/siegeln/cameleer/saas/auth/RoleEntity.java create mode 100644 src/main/java/net/siegeln/cameleer/saas/auth/RoleRepository.java create mode 100644 src/main/java/net/siegeln/cameleer/saas/auth/UserEntity.java create mode 100644 src/main/java/net/siegeln/cameleer/saas/auth/UserRepository.java create mode 100644 src/main/java/net/siegeln/cameleer/saas/auth/dto/AuthResponse.java create mode 100644 src/main/java/net/siegeln/cameleer/saas/auth/dto/RegisterRequest.java create mode 100644 src/test/java/net/siegeln/cameleer/saas/auth/AuthServiceTest.java diff --git a/src/main/java/net/siegeln/cameleer/saas/auth/AuthService.java b/src/main/java/net/siegeln/cameleer/saas/auth/AuthService.java new file mode 100644 index 0000000..c0cff5d --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/auth/AuthService.java @@ -0,0 +1,55 @@ +package net.siegeln.cameleer.saas.auth; + +import net.siegeln.cameleer.saas.audit.AuditAction; +import net.siegeln.cameleer.saas.audit.AuditService; +import net.siegeln.cameleer.saas.auth.dto.AuthResponse; +import net.siegeln.cameleer.saas.auth.dto.RegisterRequest; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +@Service +public class AuthService { + + private final UserRepository userRepository; + private final RoleRepository roleRepository; + private final PasswordEncoder passwordEncoder; + private final JwtService jwtService; + private final AuditService auditService; + + public AuthService(UserRepository userRepository, + RoleRepository roleRepository, + PasswordEncoder passwordEncoder, + JwtService jwtService, + AuditService auditService) { + this.userRepository = userRepository; + this.roleRepository = roleRepository; + this.passwordEncoder = passwordEncoder; + this.jwtService = jwtService; + this.auditService = auditService; + } + + public AuthResponse register(RegisterRequest request, String sourceIp) { + if (userRepository.existsByEmail(request.email())) { + throw new IllegalArgumentException("Email already registered"); + } + + var user = new UserEntity(); + user.setEmail(request.email()); + user.setName(request.name()); + user.setPassword(passwordEncoder.encode(request.password())); + + roleRepository.findByName("OWNER").ifPresent(role -> user.getRoles().add(role)); + + var saved = userRepository.save(user); + var token = jwtService.generateToken(saved); + + auditService.log( + saved.getId(), saved.getEmail(), null, + AuditAction.AUTH_REGISTER, null, + null, sourceIp, + "SUCCESS", null + ); + + return new AuthResponse(token, saved.getEmail(), saved.getName()); + } +} diff --git a/src/main/java/net/siegeln/cameleer/saas/auth/JwtService.java b/src/main/java/net/siegeln/cameleer/saas/auth/JwtService.java new file mode 100644 index 0000000..4254ad4 --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/auth/JwtService.java @@ -0,0 +1,19 @@ +package net.siegeln.cameleer.saas.auth; + +import org.springframework.stereotype.Service; + +@Service +public class JwtService { + + public String generateToken(UserEntity user) { + return "stub-token"; + } + + public String extractEmail(String token) { + return null; + } + + public boolean isTokenValid(String token) { + return false; + } +} diff --git a/src/main/java/net/siegeln/cameleer/saas/auth/PermissionEntity.java b/src/main/java/net/siegeln/cameleer/saas/auth/PermissionEntity.java new file mode 100644 index 0000000..52d4fba --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/auth/PermissionEntity.java @@ -0,0 +1,45 @@ +package net.siegeln.cameleer.saas.auth; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import java.util.UUID; + +@Entity +@Table(name = "permissions") +public class PermissionEntity { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @Column(name = "name", nullable = false, unique = true, length = 100) + private String name; + + @Column(name = "description") + private String description; + + public UUID getId() { + return id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } +} diff --git a/src/main/java/net/siegeln/cameleer/saas/auth/RoleEntity.java b/src/main/java/net/siegeln/cameleer/saas/auth/RoleEntity.java new file mode 100644 index 0000000..cedda48 --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/auth/RoleEntity.java @@ -0,0 +1,94 @@ +package net.siegeln.cameleer.saas.auth; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.JoinTable; +import jakarta.persistence.ManyToMany; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; + +import java.time.Instant; +import java.util.HashSet; +import java.util.Set; +import java.util.UUID; + +@Entity +@Table(name = "roles") +public class RoleEntity { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @Column(name = "name", nullable = false, unique = true, length = 50) + private String name; + + @Column(name = "description") + private String description; + + @Column(name = "built_in", nullable = false) + private boolean builtIn; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @ManyToMany(fetch = FetchType.EAGER) + @JoinTable( + name = "role_permissions", + joinColumns = @JoinColumn(name = "role_id"), + inverseJoinColumns = @JoinColumn(name = "permission_id") + ) + private Set permissions = new HashSet<>(); + + @PrePersist + protected void onCreate() { + if (createdAt == null) { + createdAt = Instant.now(); + } + } + + public UUID getId() { + return id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public boolean isBuiltIn() { + return builtIn; + } + + public void setBuiltIn(boolean builtIn) { + this.builtIn = builtIn; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public Set getPermissions() { + return permissions; + } + + public void setPermissions(Set permissions) { + this.permissions = permissions; + } +} diff --git a/src/main/java/net/siegeln/cameleer/saas/auth/RoleRepository.java b/src/main/java/net/siegeln/cameleer/saas/auth/RoleRepository.java new file mode 100644 index 0000000..ff456bd --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/auth/RoleRepository.java @@ -0,0 +1,13 @@ +package net.siegeln.cameleer.saas.auth; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; +import java.util.UUID; + +@Repository +public interface RoleRepository extends JpaRepository { + + Optional findByName(String name); +} diff --git a/src/main/java/net/siegeln/cameleer/saas/auth/UserEntity.java b/src/main/java/net/siegeln/cameleer/saas/auth/UserEntity.java new file mode 100644 index 0000000..c4965f2 --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/auth/UserEntity.java @@ -0,0 +1,122 @@ +package net.siegeln.cameleer.saas.auth; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.JoinTable; +import jakarta.persistence.ManyToMany; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; +import jakarta.persistence.Table; + +import java.time.Instant; +import java.util.HashSet; +import java.util.Set; +import java.util.UUID; + +@Entity +@Table(name = "users") +public class UserEntity { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @Column(name = "email", nullable = false, unique = true) + private String email; + + @Column(name = "password", nullable = false) + private String password; + + @Column(name = "name", nullable = false) + private String name; + + @Column(name = "status", nullable = false, length = 20) + private String status = "ACTIVE"; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + @ManyToMany(fetch = FetchType.EAGER) + @JoinTable( + name = "user_roles", + joinColumns = @JoinColumn(name = "user_id"), + inverseJoinColumns = @JoinColumn(name = "role_id") + ) + private Set roles = new HashSet<>(); + + @PrePersist + protected void onCreate() { + Instant now = Instant.now(); + if (createdAt == null) { + createdAt = now; + } + if (updatedAt == null) { + updatedAt = now; + } + } + + @PreUpdate + protected void onUpdate() { + updatedAt = Instant.now(); + } + + public UUID getId() { + return id; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public Instant getUpdatedAt() { + return updatedAt; + } + + public Set getRoles() { + return roles; + } + + public void setRoles(Set roles) { + this.roles = roles; + } +} diff --git a/src/main/java/net/siegeln/cameleer/saas/auth/UserRepository.java b/src/main/java/net/siegeln/cameleer/saas/auth/UserRepository.java new file mode 100644 index 0000000..3d13743 --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/auth/UserRepository.java @@ -0,0 +1,15 @@ +package net.siegeln.cameleer.saas.auth; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; +import java.util.UUID; + +@Repository +public interface UserRepository extends JpaRepository { + + Optional findByEmail(String email); + + boolean existsByEmail(String email); +} diff --git a/src/main/java/net/siegeln/cameleer/saas/auth/dto/AuthResponse.java b/src/main/java/net/siegeln/cameleer/saas/auth/dto/AuthResponse.java new file mode 100644 index 0000000..ba53b4a --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/auth/dto/AuthResponse.java @@ -0,0 +1,8 @@ +package net.siegeln.cameleer.saas.auth.dto; + +public record AuthResponse( + String token, + String email, + String name +) { +} diff --git a/src/main/java/net/siegeln/cameleer/saas/auth/dto/RegisterRequest.java b/src/main/java/net/siegeln/cameleer/saas/auth/dto/RegisterRequest.java new file mode 100644 index 0000000..d9e7186 --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/auth/dto/RegisterRequest.java @@ -0,0 +1,12 @@ +package net.siegeln.cameleer.saas.auth.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record RegisterRequest( + @NotBlank @Email String email, + @NotBlank String name, + @NotBlank @Size(min = 8, max = 128) String password +) { +} diff --git a/src/test/java/net/siegeln/cameleer/saas/auth/AuthServiceTest.java b/src/test/java/net/siegeln/cameleer/saas/auth/AuthServiceTest.java new file mode 100644 index 0000000..2085017 --- /dev/null +++ b/src/test/java/net/siegeln/cameleer/saas/auth/AuthServiceTest.java @@ -0,0 +1,98 @@ +package net.siegeln.cameleer.saas.auth; + +import net.siegeln.cameleer.saas.audit.AuditAction; +import net.siegeln.cameleer.saas.audit.AuditService; +import net.siegeln.cameleer.saas.auth.dto.RegisterRequest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AuthServiceTest { + + @Mock + private UserRepository userRepository; + @Mock + private RoleRepository roleRepository; + @Mock + private PasswordEncoder passwordEncoder; + @Mock + private JwtService jwtService; + @Mock + private AuditService auditService; + + private AuthService authService; + + @BeforeEach + void setUp() { + authService = new AuthService(userRepository, roleRepository, + passwordEncoder, jwtService, auditService); + } + + @Test + void register_createsUserAndReturnsToken() { + var request = new RegisterRequest("user@example.com", "Test User", "password123"); + var ownerRole = new RoleEntity(); + ownerRole.setName("OWNER"); + + when(userRepository.existsByEmail("user@example.com")).thenReturn(false); + when(passwordEncoder.encode("password123")).thenReturn("encoded-password"); + when(roleRepository.findByName("OWNER")).thenReturn(Optional.of(ownerRole)); + when(userRepository.save(any(UserEntity.class))).thenAnswer(invocation -> { + UserEntity user = invocation.getArgument(0); + // simulate ID assignment by persistence + try { + var idField = UserEntity.class.getDeclaredField("id"); + idField.setAccessible(true); + idField.set(user, java.util.UUID.randomUUID()); + } catch (Exception e) { + throw new RuntimeException(e); + } + return user; + }); + when(jwtService.generateToken(any(UserEntity.class))).thenReturn("test-jwt-token"); + + var response = authService.register(request, "127.0.0.1"); + + assertNotNull(response); + assertEquals("test-jwt-token", response.token()); + assertEquals("user@example.com", response.email()); + assertEquals("Test User", response.name()); + + // Verify audit was logged + verify(auditService).log( + any(), eq("user@example.com"), eq(null), + eq(AuditAction.AUTH_REGISTER), eq(null), + eq(null), eq("127.0.0.1"), + eq("SUCCESS"), eq(null) + ); + } + + @Test + void register_rejectsDuplicateEmail() { + var request = new RegisterRequest("existing@example.com", "Test User", "password123"); + when(userRepository.existsByEmail("existing@example.com")).thenReturn(true); + + var exception = assertThrows(IllegalArgumentException.class, + () -> authService.register(request, "127.0.0.1")); + + assertEquals("Email already registered", exception.getMessage()); + verify(userRepository, never()).save(any()); + verify(auditService, never()).log(any(), any(), any(), any(), any(), any(), any(), any(), any()); + } +}