2323 CONFIG_AUDIT_RESTORE ,
2424)
2525from app .routes import h
26+ from app .security_audit import (
27+ log_security_event ,
28+ EVENT_USER_MODIFY ,
29+ CATEGORY_ADMIN ,
30+ SEVERITY_ALERT ,
31+ SEVERITY_INFO ,
32+ )
2633from .helpers import (
2734 require_super_admin ,
2835 render_admin_config_page ,
@@ -306,6 +313,24 @@ def restore_user(user_id: str):
306313 return redirect (url_for (".list_users" ))
307314
308315
316+ def _role_code (role : UserRole ) -> str :
317+ """One role as a readable code, never a database ID."""
318+ if role .work_type_id and role .work_type :
319+ return f"{ role .role_code } :{ role .work_type .code } "
320+ if role .approval_group_id and role .approval_group :
321+ return f"{ role .role_code } :{ role .approval_group .code } "
322+ return role .role_code
323+
324+
325+ def _role_codes (user : User ) -> set [str ]:
326+ """Current roles as readable codes, never database IDs.
327+
328+ An ID is meaningless in an audit entry read a year later, and it breaks if
329+ the work type or approval group is renamed or removed.
330+ """
331+ return {_role_code (r ) for r in user .roles }
332+
333+
309334def _update_user_roles (user : User , form_data ) -> None :
310335 """
311336 Update user roles based on form data.
@@ -314,7 +339,13 @@ def _update_user_roles(user: User, form_data) -> None:
314339 - role_super_admin: "1" if checked
315340 - role_worktype_admin_<work_type_id>: "1" if checked
316341 - role_approver_<approval_group_id>: "1" if checked
342+
343+ Snapshots the role set before clearing it, and logs a security audit
344+ entry naming what was granted and revoked. The snapshot must come first;
345+ clear() plus flush() below leaves nothing to diff against afterward.
317346 """
347+ before = _role_codes (user )
348+
318349 # Clear existing roles
319350 user .roles .clear ()
320351 db .session .flush ()
@@ -348,3 +379,34 @@ def _update_user_roles(user: User, form_data) -> None:
348379 approval_group_id = ag .id ,
349380 )
350381 db .session .add (role )
382+
383+ # New roles are added via db.session.add(), not user.roles.append(). The
384+ # already-loaded `roles` collection on `user` (cached when `before` ran)
385+ # does not see them. flush() persists the FK rows; it does not refresh a
386+ # collection already loaded in memory. Expire it so the next access
387+ # requeries instead of returning the stale list.
388+ db .session .flush ()
389+ db .session .expire (user , ["roles" ])
390+ after = _role_codes (user )
391+
392+ granted = sorted (after - before )
393+ revoked = sorted (before - after )
394+ if not granted and not revoked :
395+ return
396+
397+ log_security_event (
398+ EVENT_USER_MODIFY ,
399+ category = CATEGORY_ADMIN ,
400+ severity = (
401+ SEVERITY_ALERT
402+ if ROLE_SUPER_ADMIN in granted or ROLE_SUPER_ADMIN in revoked
403+ else SEVERITY_INFO
404+ ),
405+ details = {
406+ "source" : "admin_form" ,
407+ "target_user_id" : user .id ,
408+ "target_email" : user .email ,
409+ "granted" : granted ,
410+ "revoked" : revoked ,
411+ },
412+ )
0 commit comments