fix(categories): allow reparenting existing categories via REST API (#33989) - #36440
Conversation
…33989) CategoryAPIImpl.save only wired the parent/child tree relationship for new categories, so PUT /api/v1/categories accepted a "parent" field but never moved existing ones. Existing categories are now re-parented (old tree link removed, new one added) when a differing parent is supplied, guarded by an EDIT permission check on the target parent. Adds an integration test (CategoryAPITest#reParentExistingCategory) and a Postman regression folder covering the move end-to-end. Refs: #33989 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Claude finished @dsilvam's task in 6m 28s —— View job 🔍 dotCMS Backend ReviewRe-reviewed after the latest commit ( New Issues
Existing
Resolved
The core logic is sound and the two blocking security findings are addressed. Remaining items are documentation/test-hygiene and one behavioral clarification — none block merge. |
🤖 dotBot Review (Bedrock)Reviewed 3 file(s); 2 candidate(s) → 2 confirmed, 0 uncertain (unverified, kept for review). Confirmed findings
us.deepseek.r1-v1:0 · Run: #28815147223 · tokens: in: 24132 · out: 5231 · total: 29363 · calls: 8 · est. ~$0.061 |
🔍 dotCMS Backend Review🟠 High
for (final Category currentParent : currentParents) {
if (!newParent.getInode().equals(currentParent.getInode())) {
categoryFactory.removeParent(category, currentParent);
}
}💡 Require 🟠 High
private void reParent(final Category category, final Category newParent, final User user,
final boolean respectFrontendRoles) throws DotDataException, DotSecurityException {
final List<Category> currentParents = categoryFactory.getParents(category);
// no check that newParent != category or that category is an ancestor of newParent💡 Reject the move if 🟡 Medium
category.setModDate(new Date());
categoryFactory.save(category, parent); // update + full catCache.clearCache()
...
if (!permissionAPI.doesUserHavePermission(newParent, PermissionAPI.PERMISSION_EDIT, user,
respectFrontendRoles)) {
throw new DotSecurityException(errorMsg);
}💡 Hoist the 🟡 Medium
if (UtilMethods.isSet(categoryForm.getParent())) {
parentCategory = this.categoryAPI.find(categoryForm.getParent(), user,
pageMode.respectAnonPerms);
}💡 If 🟡 Medium
return new CategoryView.Builder()
.inode(category.getInode())
.description(category.getDescription())
...
.build(); // .parent(...) is never called💡 Populate 🟡 Medium
🟡 Medium
for (final Category currentParent : currentParents) {
if (!newParent.getInode().equals(currentParent.getInode())) {
categoryFactory.removeParent(category, currentParent);
}
}💡 Collect the old-parent inodes and issue one batched delete against Next steps
|
…parent (#33989) Harden the re-parenting path added for issue #33989 in response to backend review findings: - Require EDIT on every current parent a category is detached from, not just on the new parent. Detaching restructures the source parents' children sets, so — mirroring removeChild's parent-oriented check — EDIT on the moved category alone is not sufficient. Closes a permission-bypass where a user with EDIT on the category and target parent could sever it from a parent they do not control. - Reject moves that would create a cycle (a category under itself or one of its descendants) via a visited-set ancestor walk. Prevents an authenticated DoS: category tree traversals (getAllChildren, isParent, getCategoryTree) assume an acyclic tree and would otherwise loop / stack-overflow. - Validate the move (permissions + cycle) BEFORE the DB write, so a denied request no longer pays for an UPDATE and a cluster-wide category cache flush. - REST: throw DoesNotExistException when a supplied parent inode does not resolve, instead of silently returning 200 without moving the category. Adds CategoryAPITest#reParentRejectsCycles. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Tick the box to add this pull request to the merge queue (same as
|
Proposed Changes
CategoryAPIImpl.save(...)soPUT /api/v1/categoriesactually re-parents an existing category. Previously the parent/childtreerelationship was only written for brand-new categories (gated byisANewCategory), so a supplied"parent"was silently ignored on updates and the category stayed under its original parent.removeParent+addChild). An omitted/nullparentleaves the current relationship untouched, so an update never accidentally detaches a category.EDIT(add-children) permission check on the target parent.CategoryAPITest#reParentExistingCategoryand a Postman regression folderReparent (issue #33989)in theCategorycollection.Root cause
CategoryAPIImpl.savewired thetreerelationship only insideif (isANewCategory && parent != null). For an update,isANewCategoryisfalse, soaddChildwas never called and no existing link was rewritten — the category row updated but its tree position did not.Checklist
Additional Info
Security note: re-parenting now requires
PERMISSION_EDITon the target parent, mirroring the create-time check. Permissions on the moved category itself are intentionally left untouched (nocopyPermissionson move) to avoid silently overwriting an existing permission set — flag if inheritance-on-move is the desired product behavior.Behavior change:
PUT /api/v1/categoriesnow actually moves categories where it previously ignoredparent. Clients sending a staleparenton update will now relocate the category.Out of scope: moving a category to top level via an explicit-null parent — the API cannot currently distinguish "omitted parent" from "make top-level" (both arrive as
null); would need a dedicated flag onCategoryForm(cf. theactiveProvided()pattern from #35501).Verification
CategoryAPITest#reParentExistingCategory— passed (isolated core build).Category→Reparent (issue #33989)run against a live dotCMS: 10 requests / 16 assertions, 0 failures — confirms the child moves under the new parent and is removed from the original.Refs: #33989
This PR fixes: #33989