From e7c7f801c9aedba08d2da8dd703a1dbd0dd7afd2 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 26 Apr 2026 14:48:35 +0200 Subject: [PATCH] feat(audit): emit USER_CREATED when admin creates a new user Adds USER_CREATED, USER_DELETED, GROUP_MEMBERSHIP_CHANGED to AuditKind. Injects AuditService into UserService; changes createUserOrUpdate to accept actorId and emits logAfterCommit(USER_CREATED) only on the new-user branch. Updates UserController to resolve and pass actorId. Co-Authored-By: Claude Sonnet 4.6 --- .../familienarchiv/audit/AuditKind.java | 11 +++- .../controller/UserController.java | 6 +- .../familienarchiv/service/UserService.java | 16 ++++- .../service/UserServiceTest.java | 60 +++++++++++++++++-- 4 files changed, 83 insertions(+), 10 deletions(-) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/audit/AuditKind.java b/backend/src/main/java/org/raddatz/familienarchiv/audit/AuditKind.java index 5a89f081..ef2939a0 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/audit/AuditKind.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/audit/AuditKind.java @@ -26,7 +26,16 @@ public enum AuditKind { COMMENT_ADDED, /** Payload: {@code {"commentId": "uuid", "mentionedUserId": "uuid"}} */ - MENTION_CREATED; + MENTION_CREATED, + + /** Payload: {@code {"userId": "uuid", "email": "addr"}} */ + USER_CREATED, + + /** Payload: {@code {"userId": "uuid", "email": "addr"}} */ + USER_DELETED, + + /** Payload: {@code {"userId": "uuid", "email": "addr", "addedGroups": ["Admin"], "removedGroups": []}} */ + GROUP_MEMBERSHIP_CHANGED; public static final Set ROLLUP_ELIGIBLE = Set.of( TEXT_SAVED, FILE_UPLOADED, ANNOTATION_CREATED, diff --git a/backend/src/main/java/org/raddatz/familienarchiv/controller/UserController.java b/backend/src/main/java/org/raddatz/familienarchiv/controller/UserController.java index 1813c37d..bcd8b9bd 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/controller/UserController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/UserController.java @@ -78,8 +78,10 @@ public class UserController { @PostMapping("/users") @RequirePermission(Permission.ADMIN_USER) - public ResponseEntity createUser(@Valid @RequestBody CreateUserRequest request) { - return ResponseEntity.ok(userService.createUserOrUpdate(request)); + public ResponseEntity createUser(Authentication authentication, + @Valid @RequestBody CreateUserRequest request) { + AppUser actor = userService.findByEmail(authentication.getName()); + return ResponseEntity.ok(userService.createUserOrUpdate(actor.getId(), request)); } @PutMapping("/users/{id}") diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/UserService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/UserService.java index 5cdb47a5..fe77cb87 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/UserService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/UserService.java @@ -3,6 +3,8 @@ package org.raddatz.familienarchiv.service; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.raddatz.familienarchiv.audit.AuditKind; +import org.raddatz.familienarchiv.audit.AuditService; import org.raddatz.familienarchiv.dto.AdminUpdateUserRequest; import org.raddatz.familienarchiv.dto.ChangePasswordDTO; import org.raddatz.familienarchiv.dto.CreateUserRequest; @@ -21,6 +23,7 @@ import org.springframework.transaction.annotation.Transactional; import java.util.Collection; import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.UUID; @@ -33,9 +36,10 @@ public class UserService { private final AppUserRepository userRepository; private final UserGroupRepository groupRepository; private final PasswordEncoder passwordEncoder; + private final AuditService auditService; @Transactional - public AppUser createUserOrUpdate(CreateUserRequest request) { + public AppUser createUserOrUpdate(UUID actorId, CreateUserRequest request) { log.info("Creating or updating user: {}", request.getEmail()); Set groups = new HashSet<>(); @@ -45,10 +49,12 @@ public class UserService { Optional existingUser = userRepository.findByEmail(request.getEmail()); AppUser user; + boolean isNew; if (existingUser.isPresent()) { log.info("User exists, updating: {}", request.getEmail()); user = existingUser.get().updateFromRequest(request, passwordEncoder, groups); + isNew = false; } else { log.info("Creating new user: {}", request.getEmail()); user = AppUser.builder() @@ -61,9 +67,15 @@ public class UserService { .contact(request.getContact()) .enabled(true) .build(); + isNew = true; } - return userRepository.save(user); + AppUser saved = userRepository.save(user); + if (isNew) { + auditService.logAfterCommit(AuditKind.USER_CREATED, actorId, null, + Map.of("userId", saved.getId().toString(), "email", saved.getEmail())); + } + return saved; } @Transactional diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/UserServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/UserServiceTest.java index 15c684e4..5062d2c1 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/UserServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/UserServiceTest.java @@ -2,9 +2,12 @@ package org.raddatz.familienarchiv.service; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.raddatz.familienarchiv.audit.AuditKind; +import org.raddatz.familienarchiv.audit.AuditService; import org.raddatz.familienarchiv.dto.AdminUpdateUserRequest; import org.raddatz.familienarchiv.dto.ChangePasswordDTO; import org.raddatz.familienarchiv.dto.CreateUserRequest; @@ -34,6 +37,7 @@ class UserServiceTest { @Mock AppUserRepository userRepository; @Mock UserGroupRepository groupRepository; @Mock PasswordEncoder passwordEncoder; + @Mock AuditService auditService; @InjectMocks UserService userService; // ─── findByEmail ────────────────────────────────────────────────────────── @@ -90,7 +94,7 @@ class UserServiceTest { AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("new@example.com").build(); when(userRepository.save(any())).thenReturn(saved); - AppUser result = userService.createUserOrUpdate(req); + AppUser result = userService.createUserOrUpdate(UUID.randomUUID(), req); assertThat(result).isEqualTo(saved); verify(userRepository).save(any()); @@ -108,7 +112,7 @@ class UserServiceTest { when(passwordEncoder.encode(any())).thenReturn("encoded"); when(userRepository.save(any())).thenReturn(existing); - userService.createUserOrUpdate(req); + userService.createUserOrUpdate(UUID.randomUUID(), req); verify(userRepository, times(1)).save(existing); } @@ -313,7 +317,7 @@ class UserServiceTest { AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("u@example.com").build(); when(userRepository.save(any())).thenReturn(saved); - AppUser result = userService.createUserOrUpdate(req); + AppUser result = userService.createUserOrUpdate(UUID.randomUUID(), req); assertThat(result).isEqualTo(saved); verify(groupRepository).findAllById(List.of(group.getId())); @@ -497,7 +501,7 @@ class UserServiceTest { AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("u@example.com").build(); when(userRepository.save(any())).thenReturn(saved); - userService.createUserOrUpdate(req); + userService.createUserOrUpdate(UUID.randomUUID(), req); verify(groupRepository, never()).findAllById(any()); } @@ -640,7 +644,7 @@ class UserServiceTest { AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("ng@example.com").build(); when(userRepository.save(any())).thenReturn(saved); - userService.createUserOrUpdate(req); + userService.createUserOrUpdate(UUID.randomUUID(), req); verify(groupRepository, never()).findAllById(any()); } @@ -699,6 +703,52 @@ class UserServiceTest { assertThat(result).containsExactly(g); } + // ─── audit: USER_CREATED ────────────────────────────────────────────────── + + @Test + void createUserOrUpdate_logsUserCreated_whenUserIsNew() { + UUID actorId = UUID.randomUUID(); + CreateUserRequest req = new CreateUserRequest(); + req.setEmail("new@example.com"); + req.setInitialPassword("secret"); + req.setGroupIds(List.of()); + + when(userRepository.findByEmail("new@example.com")).thenReturn(Optional.empty()); + when(passwordEncoder.encode("secret")).thenReturn("encoded"); + AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("new@example.com").build(); + when(userRepository.save(any())).thenReturn(saved); + + userService.createUserOrUpdate(actorId, req); + + @SuppressWarnings("unchecked") + ArgumentCaptor> payloadCaptor = ArgumentCaptor.forClass(java.util.Map.class); + verify(auditService).logAfterCommit( + org.mockito.ArgumentMatchers.eq(AuditKind.USER_CREATED), + org.mockito.ArgumentMatchers.eq(actorId), + org.mockito.ArgumentMatchers.isNull(), + payloadCaptor.capture()); + assertThat(payloadCaptor.getValue()).containsKey("userId"); + assertThat(payloadCaptor.getValue()).containsEntry("email", "new@example.com"); + } + + @Test + void createUserOrUpdate_doesNotLogUserCreated_whenUserAlreadyExists() { + UUID actorId = UUID.randomUUID(); + CreateUserRequest req = new CreateUserRequest(); + req.setEmail("existing@example.com"); + req.setInitialPassword("pass"); + req.setGroupIds(List.of()); + + AppUser existing = AppUser.builder().id(UUID.randomUUID()).email("existing@example.com").build(); + when(userRepository.findByEmail("existing@example.com")).thenReturn(Optional.of(existing)); + when(passwordEncoder.encode(any())).thenReturn("encoded"); + when(userRepository.save(any())).thenReturn(existing); + + userService.createUserOrUpdate(actorId, req); + + verify(auditService, never()).logAfterCommit(any(), any(), any(), any()); + } + // ─── createGroup ────────────────────────────────────────────────────────── @Test