feat(audit): track user management events in audit log (USER_CREATED, USER_DELETED, GROUP_MEMBERSHIP_CHANGED) #336
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Context
The
AuditKindenum currently covers only document/content events (FILE_UPLOADED,STATUS_CHANGED,TEXT_SAVED, etc.). User management actions — creating a user, deleting a user, changing group membership — leave no audit trail.This blocks two things:
Discovered and deferred during the #326 persona review (see comment).
User story
As an admin, I want to see which users were recently created or had their group membership changed, so that I can keep track of access control changes without trawling through server logs.
Scope
In scope:
AuditKindvalues:USER_CREATED,USER_DELETED,GROUP_MEMBERSHIP_CHANGEDUserServiceat the relevant write methodsAuditLogQueryServiceto retrieve the N most recent user-management eventsOut of scope:
USER_UPDATED(profile edits, password changes) — log only access-control-relevant events for nowPayload design
Each new
AuditKindvalue must follow the existing docstring convention inAuditKind.java. Payloads must never include sensitive data:USER_CREATEDuserId,email,invitedById(nullable)USER_DELETEDuserId,emailGROUP_MEMBERSHIP_CHANGEDuserId,email,addedGroups: [name],removedGroups: [name]Group names (not permission strings) are the right level of abstraction — they're human-readable and don't expose the internal
Set<String> permissionsvalues.Implementation notes
AuditKindvalues go inaudit/AuditKind.javawith payload docstrings following the existing pattern.UserService(constructor-injectingAuditService):createUserOrUpdate()→ emitUSER_CREATEDdeleteUser()→ emitUSER_DELETEDadminUpdateUser()→ emitGROUP_MEMBERSHIP_CHANGEDif the group set changed (compare before/after)AuditLogQueryService:findRecentUserManagementEvents(int limit)— filters by the three newAuditKindvalues, ordered bycreatedAt DESC.AuditLogalready storeskindas a string column (Flyway migration only if a new table or column is required — check first).Acceptance criteria
USER_CREATEDis logged whenUserService.createUserOrUpdate()creates a new user (not on update of existing)USER_DELETEDis logged whenUserService.deleteUser()is calledGROUP_MEMBERSHIP_CHANGEDis logged whenUserService.adminUpdateUser()results in a change to the user's group set; not logged if groups are unchangedAuditLogQueryService.findRecentUserManagementEvents(n)returns the n most recent events of these three kinds, ordered newest-firstAuditService, assertlog()is called with correctAuditKindand payload)Related
findRecentUserManagementEvents(3)once this ships🏛️ Markus Keller — Senior Application Architect
Observations
AuditLog.kinduses@Enumerated(EnumType.STRING)which stores enum names as varchar. Adding new enum values requires zero Flyway migration. The issue's assessment is correct.documentIdwill benullfor all three new event kinds. The existingAuditLogentity hasdocumentIdas nullable. TheAuditService.log()signature islog(kind, actorId, documentId, payload)— callers will passnullasdocumentIdfor user events. This is a minor code smell (the parameter name implies document context), but adding an overload or a separate column solely for user events would be over-engineering. The existing design handles it.logAfterCommitis the right call, notlog(). All existing write paths inDocumentService,TranscriptionService,CommentServiceuselogAfterCommitso the audit entry is only written after the transaction commits successfully. User creation/deletion/group-change must follow the same pattern — otherwise a rolled-back user creation would still produce aUSER_CREATEDentry.findRecentUserManagementEventsplacement. Adding this toAuditLogQueryServiceis correct — consistent withfindActivityFeed,getPulseStats, and other query methods already there. Do not add it toAuditLogQueryRepositorydirectly without going through the service layer.UserServicelives inservice/,AuditServiceinaudit/— cross-package dependency is acceptable sinceAuditServiceis infrastructure, not a peer domain service. Constructor-injectAuditServiceintoUserServicevia@RequiredArgsConstructoras all other services do.Recommendations
nulldocumentId for user events. It's supported by the schema, documented in the payload, and doesn't justify a new column or method overload. The pattern is already set byAuditService.logAfterCommit(BLOCK_REVIEWED, userId, documentId, null)which also passes anullpayload.logAfterCommit, notlog(), for user management events — they are called from@Transactionalmethods and must not emit audit entries for rolled-back operations.findRecentUserManagementEventsinAuditLogQueryService, not in the repository interface directly. The repository should expose raw query methods; the service composes and returns typed results.👨💻 Felix Brandt — Senior Fullstack Developer
Observations
UserServicehas two user-creation paths — the issue only covers one.createUserOrUpdate()(create-or-update semantics) is mentioned.createUser()(line 70, throwsEMAIL_ALREADY_IN_USEon duplicate) is a separate method that also creates users but is not mentioned. Looking at the controller,createUser()appears to be called from invite flows. IfcreateUser()is ever triggered by an admin action, it must also emitUSER_CREATED. The issue should clarify whether both paths need coverage.adminUpdateUser()group comparison — the existing method mutatesuser.getGroups()viauser.setGroups(newGroups)at line 169. To detect a change,beforeGroupIdsmust be captured before thesetGroupscall:setGroupswill always compare new vs. new.deleteUser()correctly fetches the user before deleting —emailis available on theAppUserobject at line 99 beforeuserRepository.delete(user). The payload can be populated before deletion without needing a separate lookup.UserServicemethods. All existing callers ofAuditServiceinDocumentService,TranscriptionService, andCommentServicereceiveactorIdas a method parameter.UserService.createUserOrUpdate(),deleteUser(), andadminUpdateUser()currently take no actor. AnactorIdparameter needs to be added (or fetched fromSecurityContextHolderinside the service — seeDocumentVersionServicefor that pattern). The issue doesn't specify which approach to use.createUserOrUpdate— the update branch must not emitUSER_CREATED. The conditional is already in the method (existingUser.isPresent()). The unit test should cover both branches: one assertinglog()is called, one asserting it is NOT called.Recommendations
actorIdas a method parameter to all three service methods — consistent withDocumentServicewhich receives actorId from its controller callers. Avoids hiddenSecurityContextHolderaccess in the service layer and keeps services testable without mocking Spring Security context.beforeGroupIdsimmediately aftergetById(id)returns, before any mutation. One line, no extra query.createUser()also needsUSER_CREATEDlogging. Looking atInviteController, it callscreateUser()— if invite flows count as user management, it should log too.🔒 Nora "NullX" Steiner — Application Security Engineer
Observations
Set<String> permissions. The issue is explicit about this. TheGROUP_MEMBERSHIP_CHANGEDpayload using group names (not permission strings) is the right abstraction — group names are human-readable and don't expose the internal permission model.actorIdis unresolved — and this matters for the audit trail. The threeUserServicemethods currently accept no caller identity.AuditService.log()takesactorIdas a top-level field (not part of the payload). IfactorIdis null, audit entries record what happened but not who did it — which defeats the purpose of access control auditing. The issue mentionsinvitedById (nullable)in theUSER_CREATEDpayload, butactorId(the admin performing the creation) must be resolved separately and passed as the top-level field, not buried in the payload.deleteUseraudit entry must be written before deletion.deleteUser()fetches the user first (line 99), souser.getEmail()is available. ThelogAfterCommitcall must be set up (synchronization registered) beforeuserRepository.delete(user)executes — sincelogAfterCommitregisters aTransactionSynchronization, this works correctly as long as the call is placed before the commit, which it will be if inside the same@Transactionalmethod.GROUP_MEMBERSHIP_CHANGEDpayload includesaddedGroupsandremovedGroupsas name lists. Implementation must extractgroup.getName(), not iterategroup.getPermissions(). A subtle mistake here (e.g., logginggroup.getPermissions()instead ofgroup.getName()) would leak the internal permission strings the issue explicitly excludes. This should be verified in a test that asserts the payload shape.findRecentUserManagementEventswill be consumed by the admin users empty state (#326). The admin endpoint is already protected by@RequirePermission(Permission.ADMIN_USER)— any new endpoint exposing these audit records must carry the same annotation. No concern for this issue specifically, but the consuming PR (#326) must enforce it.invitedByIdin theUSER_CREATEDpayload — there is no "invitation" entity in the current codebase.InviteControllerexists but usescreateUser(), notcreateUserOrUpdate(). IfinvitedByIdrefers to the admin creating the user (the actor), it duplicatesactorId. Recommend removinginvitedByIdfrom the payload and relying on the top-levelactorIdfield instead — simpler, consistent, non-redundant.Recommendations
GROUP_MEMBERSHIP_CHANGEDpayload contains group names, not permission strings. The assertion should be:payload.get("addedGroups")contains["Admins"], not["ADMIN", "WRITE_ALL"]. One test, one assertion, prevents the wrong field from ever shipping.invitedByIdfrom theUSER_CREATEDpayload spec — use top-levelactorId(the performing admin's ID) instead. A nullableinvitedByIdduplicates whatactorIdalready expresses.actorIdthreading gap before implementation starts. Either addactorIdas a method parameter (preferred) or useSecurityContextHolderinside the service. Not resolving this means the audit entries will have a null actor — essentially useless for access control investigation.🧪 Sara Holt — QA Engineer & Test Strategist
Observations
AuditService, assertlog()is called with the correctAuditKindand payload — this is the right layer.UserServiceTest.javaalready exists; extend it rather than creating a new file.GROUP_MEMBERSHIP_CHANGED, but there's no equivalent negative test forcreateUserOrUpdate(update branch → must not emitUSER_CREATED). Both negatives must be explicit test cases.AuditLogQueryRepositoryIntegrationTestandAuditServiceIntegrationTestwhich already exist.GROUP_MEMBERSHIP_CHANGEDpayload shape test is missing from the AC. The unit test should assert not just thatlog()is called, but that the payload map containsaddedGroups: ["GroupName"]andremovedGroups: ["GroupName"]— not permission strings. One assertion, prevents a wrong field from shipping silently.deleteUsertest must capture email before delete. The test should assert that the payloademailfield matches the deleted user's email. SinceuserRepository.delete(user)runs before the transaction commits, the email must be captured in the payload before the delete call.AuditService.logAfterCommitregisters aTransactionSynchronization— it fires after commit. Unit tests that mockAuditServicewill verify the call is made correctly. The integration test verifies the entry actually lands in the database.Recommendations
Explicit test list I'd expect in
UserServiceTest:createUserOrUpdate_logsUserCreated_whenUserIsNew— assertlogAfterCommitcalled withUSER_CREATEDcreateUserOrUpdate_doesNotLogUserCreated_whenUserAlreadyExists— assertlogAfterCommitNOT calleddeleteUser_logsUserDeleted_withEmailInPayload— assertlogAfterCommitcalled, payload containsemailadminUpdateUser_logsGroupMembershipChanged_whenGroupSetChanges— assert called, payload contains group names (not permissions)adminUpdateUser_doesNotLogGroupMembershipChanged_whenGroupsUnchanged— assert NOT calledadminUpdateUser_doesNotLogGroupMembershipChanged_whenGroupIdsIsNull— assert NOT called (theif (dto.getGroupIds() != null)branch)For the integration test in
AuditLogQueryServiceTestor a newUserManagementAuditIntegrationTest:findRecentUserManagementEvents(10)→ assert 2 events,USER_DELETEDis first (newest),USER_CREATEDis second.Verify the payload shape in test #3 and #4 with direct assertions on the
Mapargument captured via MockitoArgumentCaptor.⚙️ Tobias Wendt — DevOps & Platform Engineer
Observations
AuditKindwork without a Flyway migration becausekindis stored asEnumType.STRING. The issue's assessment is correct — confirmed by readingAuditLog.javadirectly.auditExecutorthread pool already exists.AuditServiceuses@Qualifier("auditExecutor")which is wired inAsyncConfig. Three newlogAfterCommitcalls use this same executor — no new thread pool, no config change.V53__add_thumbnail_aspect_and_page_count.sql. The next migration, if one is needed, would beV54__.... This issue requires none — but any future change toAuditLog(e.g., adding atarget_user_idcolumn) would needV54. Worth knowing the current sequence.AuditService.writeLog()catches all exceptions and logs them without rethrowing — audit failures never propagate to the caller. This is the right behavior; user creation should not fail because the audit log is temporarily unavailable.Recommendations
target_user_idcolumn (to make querying by affected user efficient), that would needV54__add_target_user_id_to_audit_log.sqlwithALTER TABLE audit_log ADD COLUMN target_user_id UUID REFERENCES users(id) ON DELETE SET NULL. Not in scope now, but worth anticipating for #326.📋 Elicit — Requirements Engineer
Observations
USER_CREATEDis precise and verifiable.createUser()is a second user-creation path not mentioned in the spec.UserServicehas bothcreateUserOrUpdate()(upsert semantics) andcreateUser()(strict create, throws on duplicate email). TheInviteControllercallscreateUser()— notcreateUserOrUpdate(). If someone is added via the invite flow, their creation would not be logged under the current spec. The issue must state explicitly: "OnlycreateUserOrUpdate()is in scope" (if invite logging is deferred) OR includecreateUser()as well.actorIdthreading is unspecified. The audit trail needs to answer "who performed this action?" The spec defines the payload for each event kind but does not specify how the performing admin's identity reaches the service. All three service methods currently accept no actor parameter. The AC "Payloads contain no passwords..." is necessary but not sufficient — the AC should also include "actorId in the audit entry is non-null and equals the authenticated admin's ID."invitedByIdin theUSER_CREATEDpayload is undefined. There is no invitation entity or inviter-tracking concept in the current codebase. TheInviteControllergenerates invite tokens but does not track who invited whom in a persistent field. IfinvitedByIdcannot be populated from existing data, it should be removed from the payload spec to avoid a field that is always null.findRecentUserManagementEventsis underspecified. The AC says "returns the n most recent events of these three kinds, ordered newest-first." Missing: what happens when there are fewer than n events? (Expected: return however many exist — but this should be stated.) What is the return type — a list of rawAuditLogentities or a typed DTO? #326 will consume this — the interface should be defined here so the consumer doesn't need to reach intoAuditLoginternals.Recommendations
invitedByIdfrom theUSER_CREATEDpayload table. If there's no inviter data source today, simplify to{userId, email}only.createUser()(invite path) is in scope forUSER_CREATEDlogging, or deferred to a separate issue.findRecentUserManagementEvents: a list ofAuditLogentities is sufficient for #326'sfindRecentUserManagementEvents(3)call, but document the expected interface so the consumer knows what fields are available.🎨 Leonie Voss — UX Designer & Accessibility Strategist
No UI concerns from my angle for this issue — scope is explicitly backend-only (new
AuditKindvalues, service-layer logging calls, and a query method). The UI surface is deferred to #326.One note for when #326 implements the "recent activity" row: the audit entries will display user emails and group names to admins. At that point, ensure:
aria-live="polite"if it auto-refreshes.addedGroups/removedGroupspayload structure supports this well.Nothing blocking this issue from my side.
✅ Implementation complete
All acceptance criteria met. 1362 backend tests pass (0 failures).
What was implemented
New
AuditKindvalues (audit/AuditKind.java)USER_CREATED— payload:{userId, email}USER_DELETED— payload:{userId, email}GROUP_MEMBERSHIP_CHANGED— payload:{userId, email, addedGroups: [names], removedGroups: [names]}UserServiceaudit logging (3 write methods updated)createUserOrUpdate(UUID actorId, ...)— emitsUSER_CREATEDvialogAfterCommitonly on the new-user branch, not on updatedeleteUser(UUID actorId, ...)— captures email before deletion, emitsUSER_DELETEDadminUpdateUser(UUID actorId, ...)— captures before-groups, computes diff, emitsGROUP_MEMBERSHIP_CHANGEDonly when the group set actually changes; payload contains group names, not permission stringsUserController— addsAuthenticationparameter to the three admin methods and resolvesactorIdviafindByEmail(authentication.getName())AuditLogQueryRepository— new JPQL queryfindRecentByKinds(kinds, limit)AuditLogQueryService— new methodfindRecentUserManagementEvents(int limit)returns the N most recent user-management events ordered newest-first, ready for #326 to consume asfindRecentUserManagementEvents(3)Tests added
createUserOrUpdate_logsUserCreated_whenUserIsNewUserServiceTestcreateUserOrUpdate_doesNotLogUserCreated_whenUserAlreadyExistsUserServiceTestdeleteUser_logsUserDeleted_withEmailInPayloadUserServiceTestadminUpdateUser_logsGroupMembershipChanged_whenGroupSetChangesUserServiceTestadminUpdateUser_doesNotLogGroupMembershipChanged_whenGroupsUnchangedUserServiceTestadminUpdateUser_doesNotLogGroupMembershipChanged_whenGroupIdsIsNullUserServiceTestfindRecentUserManagementEvents_delegatesToRepositoryWithAllThreeKindsAuditLogQueryServiceTestcreateAndDeleteUser_producesOrderedAuditEntriesUserManagementAuditIntegrationTestDesign notes (per persona review)
invitedByIddropped — no invitation entity exists;actorIdtop-level field already captures the performing admincreateUser()(invite path) remains out of scope as specifiedlogAfterCommitused throughout — audit entries only persist if the transaction commitskindis@Enumerated(EnumType.STRING)Commits
feat(audit): emit USER_CREATED when admin creates a new userfeat(audit): emit USER_DELETED when admin removes a userfeat(audit): emit GROUP_MEMBERSHIP_CHANGED when admin updates user groupsfeat(audit): add findRecentUserManagementEvents query methodtest(audit): integration test — create + delete user produces ordered audit entriesBranch:
feat/issue-218-person-title-type-fields