Repository navigation
fix(permissions): require EDIT_PERMISSIONS before PermissionAjax changes an asset's permissions - #38077
fix(permissions): require EDIT_PERMISSIONS before PermissionAjax changes an asset's permissions#38077oidacra wants to merge 2 commits into
Conversation
PermissionAjax now checks that the logged-in user has EDIT_PERMISSIONS on the asset before resetAssetPermissions or saveAssetPermissions change its permissions, the same check the REST permission endpoints apply (front-end roles don't count). Links, containers and other inodes, which are loaded without a user, now require READ before their permissions are returned or changed, and a site the user can't read is reported instead of skipped. An id that matches no asset is rejected when changing permissions; reading its permissions still returns an empty list. Rejections are written to the security log, and resetAssetPermissions rolls back when it rejects a call. Adds PermissionAjaxTest (unit) and PermissionAjaxIntegrationTest, registered in MainSuite2b.
|
Claude finished @oidacra's task in 3m 9s —— View job Code ReviewReviewed the permission-check changes in New Issues
Notes (non-blocking)
Nothing blocking. The · |
resetAssetPermissions now commits at the end of its try block and rolls back on any exception, the same way saveAssetPermissions does. Before, the finally block committed after the rollback, so a rejected call still ended in a commit.
jcastro-dotcms
left a comment
There was a problem hiding this comment.
Requesting changes. Four correctness issues, then four design and cleanup notes. Details are in the inline comments.
Correctness
permissionIndividuallyis the third DWR method that writes permissions, and it doesn't get the newcheckEditPermissionsguard. It still relies ongetNewPermissions, which lets front-end roles count, so a user who is now blocked by the save and reset methods can still break an asset's inheritance through it.- The new READ check also runs on the generic
Inodefallback. For ids that only resolve there (a contentlet passed by inode, or one with no default-language version opened without alanguageId), it now throwsDotSecurityExceptionfor non-admin users instead of returning an empty list. PermissionAjaxIntegrationTestattaches a mocked DWR container globally and never removes it. Later tests inMainSuite2binherit the mocked request.- The READ check always passes
respectFrontendRoles=false, while the other finders inretrievePermissionablehonour the flag. The same user gets different answers depending on the asset type.
Design and cleanup
5. The underlying gap is that PermissionAPI.removePermissions(asset) takes no user and checks nothing. A check inside the API would cover every caller, not only this DWR facade.
6. On the non-empty save path, EDIT_PERMISSIONS is now checked twice (checkEditPermissions and assignPermissions), with different front-end-role settings.
7. The check, SecurityLogger call and throw block is duplicated between checkEditPermissions and the READ check.
8. Every denied save now also writes a WARN with a full stack trace to the main log.
| * @throws DotDataException if the id didn't resolve to an asset | ||
| * @throws DotSecurityException if the user can't edit the asset's permissions | ||
| */ | ||
| private void checkEditPermissions(final Permissionable asset, final User user) |
There was a problem hiding this comment.
permissionIndividually doesn't get this guard. It is the third DWR method that writes permissions (line 529), and it still relies only on PermissionBitAPIImpl.getNewPermissions. That method checks EDIT_PERMISSIONS with the 3-argument doesUserHavePermission, which lets front-end roles count. So a user who has EDIT_PERMISSIONS on an asset only through a front-end role is now refused by saveAssetPermissions and resetAssetPermissions, but can still call permissionIndividually and break the asset's inheritance.
Unlike the two methods fixed here, it also never calls HibernateUtil.closeSession() in a finally. Suggest calling checkEditPermissions(asset, user) there as well and aligning its transaction handling.
| } | ||
|
|
||
|
|
||
| if (foundWithoutUser && perm != null && UtilMethods.isSet(perm.getPermissionId()) |
There was a problem hiding this comment.
This check also runs on the generic Inode fallback, so it breaks reads that used to return an empty list. Some ids only resolve through InodeFactory.getInode(assetId, Inode.class) (line 513), or through the class getClassByDBType returns for contentlet. Two examples: a contentlet passed by inode, or a contentlet with no version in the default language opened without a languageId. The resulting plain Inode uses the inode as its permission id, which has no permission rows and no parent to inherit from. doesUserHavePermission(READ, user, false) is therefore false for every non-admin user, and getAssetPermissions now throws DotSecurityException where it used to return an empty list, so the permissions tab fails.
Suggest skipping the check on this fallback, or resolving the real permissionable before checking.
|
|
||
| if (foundWithoutUser && perm != null && UtilMethods.isSet(perm.getPermissionId()) | ||
| && !APILocator.getPermissionAPI().doesUserHavePermission(perm, | ||
| PermissionAPI.PERMISSION_READ, user, false)) { |
There was a problem hiding this comment.
respectFrontendRoles is ignored here. Every other finder in retrievePermissionable (sites, folders, contentlets, templates, categories) honours the flag, but this check always passes false. When isLoggedToBackend is false, the flag is true. In that case a folder passes READ through front-end roles while a link or container inside it (the foundWithoutUser path) throws. The same user can open a folder's permissions but not those of a link in it.
Either pass respectFrontendRoles through, or document why links and containers are meant to be stricter than the rest.
| final com.dotcms.repackage.org.directwebremoting.Container dwrContainer = | ||
| mock(com.dotcms.repackage.org.directwebremoting.Container.class); | ||
| when(dwrContainer.getBean(WebContextFactory.WebContextBuilder.class)).thenReturn(webContextBuilder); | ||
|
|
There was a problem hiding this comment.
This mocked container is never removed. WebContextFactory.attach sets global state, and the class has no @AfterClass that restores or detaches it. Every later test in the same JVM (MainSuite2b) that calls WebContextFactory.get() gets this mocked request, logged in as backendUser. Those tests then depend on run order and can fail, or pass for the wrong reason, as a limited back-end user. Please restore the previous builder/container in an @AfterClass.
| PermissionAPI permissionAPI = APILocator.getPermissionAPI(); | ||
| Permissionable asset = retrievePermissionable(assetId, languageId, user, respectFrontendRoles); | ||
| checkEditPermissions(asset, user); | ||
| permissionAPI.removePermissions(asset); |
There was a problem hiding this comment.
The underlying gap is in the API, not in this facade. PermissionAPI.removePermissions(asset) takes no user and checks nothing, so every other caller that removes permissions on a user's behalf still has the same missing-authorization hole. A user-aware removePermissions(asset, user, respectFrontendRoles) that checks EDIT_PERMISSIONS, as assignPermissions already does, would close it once for all callers. A follow-up issue is fine if that is out of scope here.
|
|
||
| PermissionAPI permissionAPI = APILocator.getPermissionAPI(); | ||
| Permissionable asset = retrievePermissionable(assetId, language, user, respectFrontendRoles); | ||
| checkEditPermissions(asset, user); |
There was a problem hiding this comment.
On the non-empty path this repeats a check assignPermissions already makes, with different semantics. permissionAPI.assignPermissions already checks EDIT_PERMISSIONS, with front-end roles counting (true). checkEditPermissions checks it again with them not counting (false). Each save now runs two lookups for the same bit, with two separately maintained messages, and the two checks have to be kept in step. Consider enforcing it in one place.
| PermissionAPI.PERMISSION_EDIT_PERMISSIONS, user, false)) { | ||
| final String message = "User id: " + user.getUserId() | ||
| + " does not have permission to alter permissions on asset " + asset.getPermissionId(); | ||
| SecurityLogger.logInfo(PermissionAjax.class, message); |
There was a problem hiding this comment.
This block is duplicated. The check, SecurityLogger.logInfo and throw new DotSecurityException here are almost identical to the READ check at lines 517-524; only the permission bit and the verb in the message differ. PermissionBitAPIImpl.assignPermissions and getNewPermissions already build the same "does not have permission to alter permissions" message, so this is the third copy. A small private helper, e.g. requirePermission(permissionable, permissionType, user, action), would remove the duplication and keep the logging consistent.
| } | ||
|
|
||
| HibernateUtil.closeAndCommitTransaction(); | ||
| } catch (Exception e) { |
There was a problem hiding this comment.
Every denied save now also writes a WARN with a full stack trace. checkEditPermissions throws inside the try, so each unauthorized attempt reaches this catch and Logger.warn(this, e.getMessage(), e) on line 330. That is on top of the SecurityLogger line already written, and a user who keeps retrying can flood the main log. Consider letting DotSecurityException bypass this warn, or logging it without the stack trace.
Proposed Changes
PermissionAjax.resetAssetPermissionsandPermissionAjax.saveAssetPermissionsnow check that the logged-in user hasEDIT_PERMISSIONSon the asset before changing its permissions, the same check the REST permission endpoints apply (AssetPermissionHelper, front-end roles not counting). The check runs before the submitted list is read, so it covers every list that ends up removing the asset's permissions.retrievePermissionableloads without a user, now require READ before their permissions are returned or changed. A site the user can't read is now reported instead of being skipped by the lookup.DotDataException); reading its permissions still returns an empty list, so the Permissions tab keeps rendering (e.g. a contentlet in a language it has no version in).resetAssetPermissionsrolls back when it rejects a call.retrievePermissionableuses typed result lists instead of rawArrayList/Mapcasts.Checklist
Additional Info
Tests:
PermissionAjaxTest(unit,dotCMSmodule, 16 cases): edit-permission checks on reset and on both save branches, front-end-only roles, READ on the inode lookup, site/contentlet/folder lookups without READ, unknown ids, rollback and the security-log line.PermissionAjaxIntegrationTest(dotcms-integration, registered inMainSuite2b, 8 cases): checks the stored permission rows after each call, with a back-end user that has READ only, no permission, orEDIT_PERMISSIONS.Behavior to know about: a user whose
EDIT_PERMISSIONSon an asset comes only from front-end roles can no longer change that asset's permissions from the Permissions tab, matching the REST endpoints.Fixes dotCMS/private-issues#766