dotCMS / dotCMS/core

PUT /v1/roles/{roleId} returns the role's children duplicated 4x, with childCount inflated to match

Open
#37,303 1 comment 0 reactions 1 assignee View on GitHub

@hassandotcms is already working on this.

Since Aug 31, 2026.

Team : Modernization Type : Defect
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, and childCount equals the number of distinct children — matching what a subsequent GET on 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).
  • populatChildrenForRolesHelper is idempotent: calling populatChildrenForRoles twice on the same Role instance leaves the same list, not a doubled one.
  • The DBFQN cascade in RoleFactoryImpl.save visits 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, PUT a reparent, assert the response carries one child and childCount == 1.
  • Integration test asserting the cascade still rewrites DBFQN correctly 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

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.