PUT /v1/roles/{roleId} returns the role's children duplicated 4x, with childCount inflated to match
@hassandotcms is already working on this.
Since Aug 31, 2026.
- Dominant language
- Java
- Stars
- 970
- Forks
- 486
- Avg merge
- 3d 33m
- Merged PRs (30d)
- 170
Description
Problem Statement
PUT /api/v1/roles/{roleId} returns the updated role with its roleChildren list duplicated four times, and childCount inflated to match. A role with one child comes back reporting four.
Only the response body is wrong. The persisted rows are correct — an immediate GET on the same role returns the real children — so the damage is limited to whatever a client does with the response it just received.
That is enough to break a UI. The Roles (Beta) portlet splices the PUT response into its tree instead of refetching, which is the normal way to avoid a round-trip after a save, so reparenting a role paints every child of that role four times until the admin reloads the page. Any other client that trusts the response — an integration reading childCount to decide whether to recurse, for instance — gets the same wrong answer.
There is a second, quieter consequence. RoleFactoryImpl.save walks the subtree to rewrite DBFQN after a reparent, and it seeds that walk from the same duplicated list:
roleIdsToProcess.addAll(parentRole.getRoleChildren());
So every descendant is visited and re-saved once per duplicate. On a deep hierarchy that multiplies the write cost of a single reparent by four for no reason.
Root cause. RoleFactoryImpl.populatChildrenForRolesHelper (lines 859-870) reads the role's existing roleChildren and appends to it rather than replacing it:
List<String> childrenList = roleMap.get(row.get("parentid")) != null?
roleMap.get(row.get("parentid")).getRoleChildren(): null;
if (childrenList == null) { childrenList = new ArrayList<>(); }
childrenList.add(row.get("childid"));
A Role that arrives already populated therefore accumulates. Within one save() the same instance passes through populatChildrenForRoles more than once — in the DBFQN cascade loop and again near the end of the method — and it arrives populated in the first place because RoleHelper.updateRole copies from the instance loadRoleById returns, which is cache-resident and already carries its children.
toRoleViews then faithfully resolves whatever ids are in the list, and RoleView.childCount is computed as getRoleChildren().size(), so both fields carry the duplication out to the wire.
It is not cumulative across requests: a second PUT also returns four, not eight.
Steps to Reproduce
Any authenticated CMS Admin, against a clean instance. No UI needed.
A=admin@dotcms.com:admin
H='Content-Type: application/json'
BASE=http://localhost:8080
mk() { curl -s -u $A -H "$H" -X POST $BASE/api/v1/roles -d "$1" | jq -r '.entity.id'; }
TARGET=$(mk '{"roleName":"TmpTarget","canEditUsers":true,"canEditPermissions":true,"canEditLayouts":true}')
PARENT=$(mk '{"roleName":"TmpParent","canEditUsers":true,"canEditPermissions":true,"canEditLayouts":true}')
mk "{\"roleName\":\"TmpChild\",\"parentRoleId\":\"$PARENT\",\"canEditUsers\":true,\"canEditPermissions\":true,\"canEditLayouts\":true}" >/dev/null
# 1 child, as expected
curl -s -u $A "$BASE/api/v1/roles/$PARENT?loadChildrenRoles=true" \
| jq '{childCount: .entity.childCount, children: (.entity.roleChildren|map(.name))}'
# reparent -> the response reports 4
curl -s -u $A -H "$H" -X PUT "$BASE/api/v1/roles/$PARENT" \
-d "{\"roleName\":\"TmpParent\",\"parentRoleId\":\"$TARGET\",\"canEditUsers\":true,\"canEditPermissions\":true,\"canEditLayouts\":true}" \
| jq '{childCount: .entity.childCount, children: (.entity.roleChildren|map(.name))}'
# GET again -> back to 1, so nothing was persisted wrong
curl -s -u $A "$BASE/api/v1/roles/$PARENT?loadChildrenRoles=true" \
| jq '{childCount: .entity.childCount, children: (.entity.roleChildren|map(.name))}'
Observed:
GET before -> { "childCount": 1, "children": ["TmpChild"] }
PUT move -> { "childCount": 4, "children": ["TmpChild","TmpChild","TmpChild","TmpChild"] }
GET after -> { "childCount": 1, "children": ["TmpChild"] }
The reparent itself is applied correctly; parent points at TmpTarget afterwards.
Reproduced on a local instance built from main.
Acceptance Criteria
-
PUT /api/v1/roles/{roleId}returns each child of the updated role exactly once, andchildCountequals the number of distinct children — matching what a subsequentGETon the same role returns. - The same holds for a plain field update (no reparent) and for a reparent, including a move to root (
parentRoleId: null). -
populatChildrenForRolesHelperis idempotent: callingpopulatChildrenForRolestwice on the sameRoleinstance leaves the same list, not a doubled one. - The
DBFQNcascade inRoleFactoryImpl.savevisits each descendant once per reparent. A subtree of N roles produces N updates, not 4N. - Integration test covering the reproduction above: create parent + one child,
PUTa reparent, assert the response carries one child andchildCount == 1. - Integration test asserting the cascade still rewrites
DBFQNcorrectly for grandchildren and below after the fix — the dedup must not skip descendants.
dotCMS Version
Reproduced on main (local build). The populatChildrenForRolesHelper accumulate-instead-of-replace behaviour is long-standing legacy code and is very unlikely to be a recent regression, so released versions are expected to be affected too. Worth confirming against the current LTS.
Severity
Medium - Some functionality impacted
Links
NA
Frontend context. The Roles (Beta) portlet ships a client-side guard for this in dotCMS/core#37260: on reparent it keeps the roleChildren and childCount it already holds rather than taking them from the PUT response. That was needed anyway — the response only ever hydrates two levels, so lazy-loaded grandchildren are not in it — but it also means the visible symptom is already masked there. Please do not treat the portlet looking correct as evidence the endpoint is fixed; use the curl reproduction above.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Assessment
This issue has not been assessed yet.