feat: add user entity, registration, and RBAC model

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-30 10:23:59 +02:00
parent cdd19e180e
commit aff10704e0
10 changed files with 481 additions and 0 deletions

View File

@@ -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());
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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<PermissionEntity> 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<PermissionEntity> getPermissions() {
return permissions;
}
public void setPermissions(Set<PermissionEntity> permissions) {
this.permissions = permissions;
}
}

View File

@@ -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<RoleEntity, UUID> {
Optional<RoleEntity> findByName(String name);
}

View File

@@ -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<RoleEntity> 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<RoleEntity> getRoles() {
return roles;
}
public void setRoles(Set<RoleEntity> roles) {
this.roles = roles;
}
}

View File

@@ -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<UserEntity, UUID> {
Optional<UserEntity> findByEmail(String email);
boolean existsByEmail(String email);
}

View File

@@ -0,0 +1,8 @@
package net.siegeln.cameleer.saas.auth.dto;
public record AuthResponse(
String token,
String email,
String name
) {
}

View File

@@ -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
) {
}

View File

@@ -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());
}
}