Consolidate role permission handling at the controller/service boundary#5948
Conversation
There was a problem hiding this comment.
Pull request overview
This PR tightens role-assignment security by enforcing a clear controller/service boundary for role changes (preventing Grails/GORM data binding from implicitly changing roles), centralizing permission checks in UserService, and ensuring RoleInterceptor protects user.show/user.edit.
Changes:
- Extract role IDs in
UserControllerand pass them explicitly toUserService; add service-side rejection of role-shaped keys inparams. - Consolidate role and location-role permission checks/validation in
UserService, and movedeleteLocationRolelogic into the service. - Add/expand Spock coverage for interceptor routing rules and controller/service role-validation behavior; add new i18n message keys.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
grails-app/services/org/pih/warehouse/core/UserService.groovy |
Adds explicit role-change APIs, permission checks, params rejection, and moves location-role deletion into the service. |
grails-app/controllers/org/pih/warehouse/user/UserController.groovy |
Extracts/removes role keys prior to binding and switches to new service signatures with localized error surfacing. |
grails-app/controllers/org/pih/warehouse/RoleInterceptor.groovy |
Requires manager access for user.show and user.edit. |
grails-app/i18n/messages.properties |
Adds new message keys for clearer role-change and params-boundary errors. |
src/test/groovy/org/pih/warehouse/RoleInterceptorSpec.groovy |
New spec covering RoleInterceptor.need* routing decisions, including user.show/edit. |
src/test/groovy/org/pih/warehouse/core/UserServiceRoleValidationSpec.groovy |
New spec validating role permission rules and rejecting role-related keys inside params. |
src/test/groovy/org/pih/warehouse/core/UserControllerSpec.groovy |
Updates test setup to ensure session.user.id exists for new service signatures. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if (requestedRoleIds) { | ||
| validateAndApplyRoleChanges(requestingUser, userInstance, requestedRoleIds) | ||
| } | ||
| if (params.locationRolePairs) { |
There was a problem hiding this comment.
Fair play and good catch. Unfortunately the proposed fix, while cleaner, could actually make things worse. The controller's popRoleParamsBeforeBinding(params) does params.list('roles'), which returns [] when no role keys are in the request. With the current truthy guard, that empty list collapses to "no role change requested" and existing roles are preserved. That's good, that's what we want.
If I change the service to use != null, without changes elsewhere, then the same empty list now enters validateAndApplyRoleChanges([]), which computes removed = current - [] = all current roles and tries to strip every role the user has. I believe the implication here is that any edit form that doesn't touch the role checkboxes would silently clear them.
The complete fix needs changes in multiple places: the service uses != null / containsKey, and popRoleParamsBeforeBinding (plus the equivalent for locationRoles) distinguishes "no role keys present" (return null) from "explicitly empty" (return []). There also is a UX component. How do we (want to) express "clear all roles" both now, and in the future?
There was a problem hiding this comment.
@jmiranda I believe you were musing about an identical or similar concern elsewhere. What do you think of this?
| try { | ||
| String userId = userService.deleteLocationRole(params.id, session.user.id) | ||
| redirect(action: "edit", id: userId) | ||
| } catch (ValidationException e) { | ||
| flash.message = e.message // the service localizes its messages for us | ||
| redirect(action: "edit", id: locationRole?.user?.id) |
There was a problem hiding this comment.
I think this is a great point, but I want to address it in a separate PR since it's not strictly in the scope of the work for this one.
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## release/0.9.8 #5948 +/- ##
================================================
Coverage ? 10.78%
Complexity ? 1707
================================================
Files ? 807
Lines ? 47638
Branches ? 11245
================================================
Hits ? 5140
Misses ? 41669
Partials ? 829 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
25dc2a5 to
8969a7e
Compare
8969a7e to
cee5b55
Compare
cee5b55 to
385bb4d
Compare
UserController used to bind request params directly to the User domain, which would allow Grails' data binding rules, not our policy, to determine which roles could be assigned. Extract role IDs at the controller and pass them to the service as explicit arguments; make the service reject any role-shaped keys still in the params map (`rejectRoleKeysInParams()`) so the boundary is enforced from both sides. A `popRoleParamsBeforeBinding()` helper applies the same extraction pattern consistently in `save()` and `update()`. Consolidate the permission checks themselves into `checkCanAddOrRemoveRoles()` and `checkCanAddOrRemoveLocationRoles()`, used by every path that can change role assignments: `saveUser`, `updateUser`, `saveLocationRole`, `deleteLocationRole`. Role validation logic moves into `validateAndApplyRoleChanges()` and `validateAndApplyLocationRoleChanges()`, which add and remove roles one at a time to steer clear of any GORM/Hibernate cascade quirks. `checkCanAddOrRemove*Roles()` takes the target `User` as a parameter so validation errors attach to the right object. A new `rejectRoleChange` helper throws `ValidationException` with a localized message that UserController surfaces directly in `flash.message` instead of re-localizing. Separately, `user.edit` and `user.show` had no entry in any of `RoleInterceptor`'s role lists. Any authenticated, browse-capable user could reach them through the default fall-through path. Add them to `managerActions` so the interceptor enforces at least manager-level access for both. This gets exercise in a big Spock table in `RoleInterceptorSpec`. While I was in there: - Hoisted `deleteLocationRole` logic from `UserController` into `UserService`. - Added five message keys to clarify error conditions to users. - Added Javadoc on `saveUser`, `updateUser`, and `rejectRoleKeysInParams()` to explain why role IDs must not travel inside the params map. - Covered the controller/service boundary with a new `UserServiceRoleValidationSpec` exercising role assignment, role removal, and params-map rejection. - Updated `UserControllerSpec` setup to stub `session.user`, since `saveUser`'s new signature reads `session.user.id` to identify the requesting user. - Used idiomatic Spock in `RoleInterceptorSpec`: `@Unroll` hoisted to the class, `||` separator marking expected output, `assert` used in expectations that delegate via a helper method. Signed-off-by: Matthew Pearson <[email protected]>
385bb4d to
f2eae83
Compare
There was a problem hiding this comment.
locationRoleDataService is no longer used after changes in that PR. I would even recommend removing deleteLocationRole in LocationRoleDataService, since that method bypasses the new checkCanAddOrRemoveRoles, and someone could use it in the future to open that gate again.
There was a problem hiding this comment.
Great catch! It's removed in 2861ac7. Thanks for spotting this.
| def deleteLocationRole() { | ||
| String userId = locationRoleDataService.deleteLocationRole(params.id) | ||
| redirect(action: "edit", id: userId) | ||
| LocationRole locationRole = LocationRole.get(params.id) |
There was a problem hiding this comment.
Just a small duplication: userService does the same in his first line of deleteLocationRole
There was a problem hiding this comment.
Yeah, it's cleaner to pass the role object itself rather than its id between these two. So that's what happens now in 2861ac7.
Remove unused `user.errors.locationRolesNotAllowedOnCreate.message`. Remove unused `LocationRoleDataService.deleteLocationRole` helper (plus the now-orphaned import and field in `UserController`). Throw `ValidationException` rather than `IllegalArgumentException` from `saveUser` and `rejectRoleKeysInParams` so `UserController`'s existing catch produces a sensible error view. `UserService.deleteLocationRole` now takes a `LocationRole` entity instead of an id, so the controller's existing lookup isn't repeated inside the service. Update `UserServiceRoleValidationSpec` to match. Signed-off-by: Matthew Pearson <[email protected]>
|
@alannadolny Thank you very much for the review! I hope that 2861ac7 addresses all of your comments. |
| user?.removeFromLocationRoles(locationRole) | ||
| locationRole?.delete() | ||
| user?.save(failOnError: true) | ||
| return user?.id |
There was a problem hiding this comment.
I am ok with that, just a loose thought:
Weirdly, we are returning the user ID from a function that deletes the location role.
UserController used to bind request params directly to the User domain, which would allow Grails' data binding rules, not our policy, to determine which roles could be assigned.
Extract role IDs at the controller and pass them to the service as explicit arguments; make the service reject any role-shaped keys still in the params map (
rejectRoleKeysInParams()) so the boundary is enforced from both sides. ApopRoleParamsBeforeBinding()helper applies the same extraction pattern consistently insave()andupdate().Consolidate the permission checks themselves into
checkCanAddOrRemoveRoles()andcheckCanAddOrRemoveLocationRoles(), used by every path that can change role assignments:saveUser,updateUser,saveLocationRole,deleteLocationRole.Role validation logic moves into
validateAndApplyRoleChanges()andvalidateAndApplyLocationRoleChanges(), which add and remove roles one at a time to steer clear of any GORM/Hibernate cascade quirks.checkCanAddOrRemove*Roles()takes the targetUseras a parameter so validation errors attach to the right object. A newrejectRoleChangehelper throwsValidationExceptionwith a localized message that UserController surfaces directly inflash.messageinstead of re-localizing.Separately,
user.editanduser.showhad no entry in any ofRoleInterceptor's role lists. Any authenticated, browse-capable user could reach them through the default fall-through path. Add them tomanagerActionsso the interceptor enforces at least manager-level access for both. This gets exercise in a big Spock table inRoleInterceptorSpec.While I was in there:
deleteLocationRolelogic fromUserControllerintoUserService.saveUser,updateUser, andrejectRoleKeysInParams()to explain why role IDs must not travel inside the params map.UserServiceRoleValidationSpecexercising role assignment, role removal, and params-map rejection.UserControllerSpecsetup to stubsession.user, sincesaveUser's new signature readssession.user.idto identify the requesting user.RoleInterceptorSpec:@Unrollhoisted to the class,||separator marking expected output,assertused in expectations that delegate via a helper method.