Skip to content

#36936: feat(roles): add PUT /v1/roles/{roleId} for role update - #37012

Open
hassandotcms wants to merge 3 commits into
mainfrom
36936-roles-api-put-role-update
Open

#36936: feat(roles): add PUT /v1/roles/{roleId} for role update#37012
hassandotcms wants to merge 3 commits into
mainfrom
36936-roles-api-put-role-update

Conversation

@hassandotcms

Copy link
Copy Markdown
Member

Proposed Changes

  • PUT /api/v1/roles/{roleId} — update role name, key, description, can-grant flags,
    and parent. Replaces DWR RoleAjax#updateRole (Angular portlet migration, Dojo to Angular: Roles and Tools Portlet #36909)
  • parentRoleId: null → role becomes root (DWR parity)
  • 404 missing role/parent, 403 system/locked, 400 invalid name, 409 duplicate key/name
  • 400 on reparent cycles — new guard, legacy had none server-side
  • Auth: backend user + roles portlet + CMS admin (same gate as POST /v1/roles; stricter
    than DWR's users-portlet check — intentional). Gate extracted, shared with POST
  • Response = ResponseEntityRoleDetailView, same shape as GET /v1/roles/{roleid}
  • RoleHelper@ApplicationScoped CDI; update logic @WrapInTransaction
  • Regenerated openapi.yaml

Checklist

  • 15 integration tests in RoleResourceIntegrationTest (MainSuite3a): all field updates,
    reparent to other/root/self/cycle, 403/404/409 cases, POST create regression
  • No breaking changes: additive endpoint; DWR and POST behavior unchanged

Fixes #36936

…eparent

* New v1 REST endpoint replacing DWR RoleAjax#updateRole for the Angular
  Roles & Tools portlet (epic #36909): updates name, key, description,
  can-grant flags and parent; null parentRoleId reparents to root (DWR parity)
* Guards: 404 missing role/parent, 403 system/locked roles, 400 invalid name
  and reparent cycles (net-new corruption guard), 409 duplicate key/name
* Auth: backend user + roles portlet + CMS admin, shared gate extracted and
  reused by POST /v1/roles (behavior unchanged, regression-tested)
* RoleHelper promoted to @ApplicationScoped CDI with the update logic under
  @WrapInTransaction; response reuses RoleView/ResponseEntityRoleDetailView
  (same shape as GET /v1/roles/{roleid})
* 15 integration tests (TDD red->green), registered in MainSuite3a
* Regenerated openapi.yaml
@github-actions github-actions Bot added the Area : Backend PR changes Java/Maven backend code label Aug 11, 2026
@claude

claude Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Claude finished @hassandotcms's task in 3m 1s —— View job


Code Review: PUT /v1/roles/{roleId}

I reviewed the diff (RoleHelper, RoleResource, openapi.yaml, tests) against origin/main, and traced the supporting APIs (RoleAPIImpl.save, findRoleHierarchy, ByteBuddyFactory).

Verified correct:

  • Cycle guardfindRoleHierarchy(parentRole) walks getParent() up to root and includes the node itself (RoleAPIImpl:108-121), so checking whether roleId is in that set correctly detects "target parent is a descendant of the role." Logic is sound; self-parent is caught first.
  • @WrapInTransaction works despite new RoleHelper() — transactions are woven at the bytecode level by ByteBuddy over any class in the com.dotcms/com.dotmarketing whitelist (ByteBuddyFactory:71-75,196-210), independent of how the instance is constructed. No missing-transaction bug.
  • Exception mapping matches sourceRoleAPIImpl.save throws the com.dotmarketing.business variants of DuplicateRoleKeyException/DuplicateRoleException and RoleNameException (RoleAPIImpl:277-295), which are exactly the types imported and caught in RoleHelper.updateRole. The 409/400 mapping fires correctly.
  • System/locked pre-check mirrors save's own guard and surfaces a clean 403 instead of a raw DotStateException.
  • Strong test coverage — 15 integration tests including reparent/cycle/self, 403/404/409, non-admin, roles-portlet-without-admin, and full-replace reset.

New Issues

  • 🟡 Medium: RoleResource.java:97 / RoleHelper.java:37-49RoleHelper is annotated @ApplicationScoped with an @Inject/@VisibleForTesting constructor, but RoleResource still holds it as private final RoleHelper roleHelper = new RoleHelper();. The CDI scope and @Inject are never exercised for this instance — the injected RoleAPI seam only works in unit tests that construct the helper directly, and at runtime it silently falls back to the no-arg APILocator constructor. This works, but the annotations are misleading dead metadata. Either @Inject the helper into RoleResource (consistent with the CDI intent) or drop @ApplicationScoped and keep it a plain helper. Non-blocking.

Existing

  • 🟡 Medium: RoleResource.java:296-297 (POST create path) — roleAPI.loadRoleById(roleForm.getParentRoleId()) is dereferenced via parentRole.getId() with no null/existence check, so a POST with a nonexistent parentRoleId yields a 500 (NPE) rather than the clean 404 that the new PUT path returns for the same case. This is pre-existing behavior (the logic only shifted indentation in this diff, not changed), so it does not block this PR — but since PUT now establishes the correct pattern via RoleHelper, it would be worth aligning POST. Fix this →

Overall this is a clean, well-tested addition. No blocking issues — the two items above are non-blocking polish.
· 36936-roles-api-put-role-update

@hassandotcms
hassandotcms marked this pull request as ready for review August 11, 2026 15:57
…th a test

* OpenAPI description now spells out that PUT overwrites every field:
  omitted booleans reset to false, omitted roleKey/description are cleared,
  omitted parentRoleId reparents to root (DWR parity)
* New IT testUpdateRole_fullReplace_omittedFieldsAreReset pins the contract
  so drift to merge/PATCH semantics is a deliberate, test-breaking change
* Addresses claude[bot] review finding on PR #37012

@fabrizzio-dotCMS fabrizzio-dotCMS left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Solid PR overall — good docs, real integration coverage, OpenAPI regenerated, and the auth gate extraction doesn't relax POST (it already required roles portlet + CMS admin) while improving the SecurityLogger call site. I also verified the two things I was most worried about and they're fine: reparenting descendants is handled (RoleFactoryImpl.save recomputes the FQN of children/grandchildren), and @WrapInTransaction still applies even though RoleResource instantiates the helper with new (it's woven by ByteBuddy per package, not a CDI interceptor).

One blocking issue though: the update mutates the cached Role instance before validating, so a rejected request leaves poisoned state in the local cache. Details inline, plus a few smaller notes.

role.getName(), role.getId()));
}

role.setName(roleForm.getRoleName());

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

In-place mutation of the cached Role — poisons the cache when the update is rejected.

roleAPI.loadRoleById() returns the cached instance, not a copy: RoleFactoryImpl.getRoleById does r = rc.get(roleId) and RoleCacheImpl.get returns the reference straight out of the cache region. So these setters mutate the object that every other reader on this node will get.

Failure scenario: PUT a roleKey that already belongs to another role. roleAPI.save throws DuplicateRoleKeyException, the transaction rolls back, the client correctly gets a 409 — but the in-memory Role keeps the rejected name/key, so a subsequent GET /v1/roles/{id} on that node returns phantom values until a cache flush. Worse, RoleCacheImpl.add also indexes the role under keyGroup + roleKey, so the cache ends up holding a role advertising a roleKey that doesn't exist in the DB. Same applies to the 400 (invalid name), 400 (cycle) and 404 (missing parent) paths — note the parent guards below run after name/key/description have already been mutated.

Suggested fix: validate everything first, then mutate a detached copy (new Role() + setters, or BeanUtils.cloneBean) and pass that to save. This is exactly what RoleFactoryImpl.save already does internally — it loads a fresh Hibernate instance and copies properties onto it rather than persisting the caller's object. A CacheLocator.getRoleCache().remove(role) in the catch blocks would be a minimum mitigation, but working on a copy is the clean fix.

// findRoleHierarchy walks getParent() up to the root, so it returns the proposed
// parent and all its ancestors — if the edited role is among them, the reparent
// would create a cycle
for (final Role ancestor : this.roleAPI.findRoleHierarchy(parentRole)) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Reuse the existing hierarchy API. This loop is exactly roleAPI.isParentRole(role, parentRole) (RoleAPIImpl:583) — same findRoleHierarchy walk, already @CloseDBIfOpened. Since self-parenting is guarded a few lines above, isParentRole excluding the child itself is not a problem here.

Also worth knowing about the primitive you're building on: findRoleHierarchy does while(!role.getParent().equals(role.getId()) && i < 100) — it NPEs if any ancestor has a null parent (the factory has if(UtilMethods.isSet(r.getParent())) checks, so unset parents are considered possible), and it silently truncates at 100 levels, which means on an already-corrupted hierarchy this guard passes without detecting anything. Not introduced by this PR, but the new endpoint is now the main caller.

* Helper to encapsulate Roles logic
* @author jsanca
*/
@ApplicationScoped

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Half-applied CDI. RoleHelper becomes @ApplicationScoped with an @Inject constructor, but RoleResource still holds private final RoleHelper roleHelper = new RoleHelper();, so the bean is never resolved through CDI and the annotation is effectively dead.

To be clear, nothing is broken: RoleAPI does have a producer (APILocatorProducers#getRoleAPI) so the bean would be satisfiable, and @WrapInTransaction still takes effect because it's woven by the ByteBuddy agent over the com.dotcms package (ByteBuddyFactory), not by a CDI interceptor. But either inject the helper into the resource or drop the CDI annotations, otherwise the next reader will assume proxying is in play.

final List<String> roleChildrenIdList = null != updatedRole.getRoleChildren()
? updatedRole.getRoleChildren() : new ArrayList<>();
for (final String childRoleId : roleChildrenIdList) {
childrenRoles.add(new RoleView(this.roleAPI.loadRoleById(childRoleId), new ArrayList<>()));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Minor: N+1 loadRoleById to build the children views. It mirrors what GET /{roleid} already does so it's consistent, and it's cache-backed, but for a role with many children this is one call per child on every update.

content = @Content(mediaType = "application/json"))
})
@PUT
@Path("/{roleId}")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Nit: the existing GET on this resource uses @Path("/{roleid}") (lowercase id) while this uses {roleId}. Harmless for JAX-RS matching, but it makes the two endpoints look like different paths at a glance — worth aligning.

* Expected Result: 409 ConflictException (DuplicateRoleKeyException from RoleAPIImpl.save).
*/
@Test(expected = ConflictException.class)
public void testUpdateRole_duplicateKey_conflict() throws Exception {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Coverage gap that maps to the cache issue above. The rejection tests assert the exception type only (or, for the cycle test, only parent — which happens to be mutated after the throw, so it passes). None of them re-read the role and assert the other fields survived a rejected update, which is precisely how the cached-instance mutation slips through.

Suggest adding to testUpdateRole_duplicateKey_conflict / testUpdateRole_invalidName_badRequest / testUpdateRole_missingParent_notFound: after the expected exception, roleAPI.loadRoleById(role.getId()) and assert name, key and description still match the original. Those assertions should fail on the current implementation.

ContentToStringUtilTest.class,
CacheResourceIntegrationTest.class,
InodeExistenceCheckIntegrationTest.class,
com.dotcms.rest.api.v1.system.role.RoleResourceIntegrationTest.class,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Nit: every other entry in this suite is referenced by simple name with an import at the top; this one is a fully-qualified inline reference. Add the import for consistency.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Area : Backend PR changes Java/Maven backend code

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

[Task] Roles API: add PUT /v1/roles/{roleId} for role update + reparent

2 participants