Skip to content

Consolidate role permission handling at the controller/service boundary#5948

Merged
awalkowiak merged 2 commits into
release/0.9.8from
user-management-0.9.8
May 29, 2026
Merged

Consolidate role permission handling at the controller/service boundary#5948
awalkowiak merged 2 commits into
release/0.9.8from
user-management-0.9.8

Conversation

@mdpearson

Copy link
Copy Markdown
Member

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.

Copilot AI review requested due to automatic review settings May 27, 2026 18:45
@mdpearson mdpearson self-assigned this May 27, 2026
@github-actions github-actions Bot added domain: backend Changes or discussions relating to the backend server domain: l10n Changes or discussions relating to localization & Internationalization labels May 27, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 UserController and pass them explicitly to UserService; add service-side rejection of role-shaped keys in params.
  • Consolidate role and location-role permission checks/validation in UserService, and move deleteLocationRole logic 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.

Comment on lines +76 to +79
if (requestedRoleIds) {
validateAndApplyRoleChanges(requestingUser, userInstance, requestedRoleIds)
}
if (params.locationRolePairs) {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jmiranda I believe you were musing about an identical or similar concern elsewhere. What do you think of this?

Comment thread grails-app/controllers/org/pih/warehouse/user/UserController.groovy
Comment on lines +486 to +491
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)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See #5949, targeting develop.

@codecov

codecov Bot commented May 27, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 30.15873% with 88 lines in your changes missing coverage. Please review.
⚠️ Please upload report for BASE (release/0.9.8@a599007). Learn more about missing BASE report.

Files with missing lines Patch % Lines
...services/org/pih/warehouse/core/UserService.groovy 21.27% 67 Missing and 7 partials ⚠️
...llers/org/pih/warehouse/user/UserController.groovy 39.13% 14 Missing ⚠️
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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@mdpearson
mdpearson force-pushed the user-management-0.9.8 branch 2 times, most recently from 25dc2a5 to 8969a7e Compare May 27, 2026 19:15
@mdpearson
mdpearson force-pushed the user-management-0.9.8 branch from 8969a7e to cee5b55 Compare May 27, 2026 19:53
@mdpearson
mdpearson requested review from awalkowiak and jmiranda May 27, 2026 19:56
@mdpearson
mdpearson force-pushed the user-management-0.9.8 branch from cee5b55 to 385bb4d Compare May 27, 2026 20:13
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]>
@mdpearson
mdpearson force-pushed the user-management-0.9.8 branch from 385bb4d to f2eae83 Compare May 27, 2026 20:23
Comment thread grails-app/i18n/messages.properties Outdated
Comment thread grails-app/controllers/org/pih/warehouse/user/UserController.groovy

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a small duplication: userService does the same in his first line of deleteLocationRole

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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]>
@mdpearson

Copy link
Copy Markdown
Member Author

@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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am ok with that, just a loose thought:
Weirdly, we are returning the user ID from a function that deletes the location role.

@awalkowiak
awalkowiak merged commit 788cace into release/0.9.8 May 29, 2026
7 checks passed
@awalkowiak
awalkowiak deleted the user-management-0.9.8 branch May 29, 2026 08:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

domain: backend Changes or discussions relating to the backend server domain: l10n Changes or discussions relating to localization & Internationalization

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants