userMap = this.addRequestPasswordAttr(user, adminRoleIds);
+ if (includeRoles && !userMap.isEmpty()) {
+ userMap.put(ROLES_PARAM, this.directRolesOf(user, usersRootRoleId));
+ }
+ return userMap;
+ }
+
+ /**
+ * Returns the Roles the given User holds directly -- i.e., rows in {@code users_cms_roles} -- as minimal
+ * {@link SmallRoleView}s. Roles reached through the hierarchy (children of a held Role) are excluded, and so is
+ * the User's own personal Role, mirroring the legacy Users portlet Roles tab ({@code UserAjax#getUserRoles}).
+ * Personal Roles are recognized the way the legacy code did -- their ID-based {@code DBFQN} hangs under the
+ * {@code cms_users} root Role -- rather than through {@link Role#isUser()}, whose name-based {@code FQN} check
+ * would also swallow any ordinary root Role whose name happens to start with {@code "User"}.
+ *
+ * @param user The {@link User}.
+ * @param usersRootRoleId The ID of the {@code cms_users} root Role, or {@code null} to skip the exclusion.
+ *
+ * @return The list of direct Roles, possibly empty.
+ *
+ * @throws DotDataException An error occurred when accessing the data source.
+ */
+ private List directRolesOf(final User user, final String usersRootRoleId) throws DotDataException {
+ return this.roleAPI.loadRolesForUser(user.getUserId(), false).stream()
+ .filter(role -> !isPersonalRole(role, usersRootRoleId))
+ .map(role -> new SmallRoleView(role.getName(), role.getId(), role.getRoleKey(), false))
+ .collect(Collectors.toList());
+ }
+
+ private static boolean isPersonalRole(final Role role, final String usersRootRoleId) {
+ return UtilMethods.isSet(usersRootRoleId) && UtilMethods.isSet(role.getDBFQN())
+ && role.getDBFQN().contains(usersRootRoleId);
+ }
+
+ /**
+ * Resolves the ID of the {@code cms_users} root Role every personal Role hangs from. It is a system Role that is
+ * always present; should it be missing, a warning is logged and {@code null} is returned so the roles list is
+ * still produced, just without the personal-Role exclusion.
*
- * @param user The {@link User} object.
+ * @return The root Role ID, or {@code null} if it cannot be resolved.
*
- * @return The {@link Map} containing the User data.
+ * @throws DotDataException An error occurred when accessing the data source.
*/
- private Map userToMap(final User user) {
- return addRequestPasswordAttr(user, null);
+ private String usersRootRoleId() throws DotDataException {
+ final Role usersRoot = this.roleAPI.loadRoleByKey(RoleAPI.USERS_ROOT_ROLE_KEY);
+ if (null == usersRoot || !UtilMethods.isSet(usersRoot.getId())) {
+ Logger.warn(this, "Users root role '" + RoleAPI.USERS_ROOT_ROLE_KEY
+ + "' not found; personal roles will not be excluded from the users list");
+ return null;
+ }
+ return usersRoot.getId();
}
/**
diff --git a/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml b/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml
index d2ae160c6fbb..ba5f782211b0 100644
--- a/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml
+++ b/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml
@@ -18929,8 +18929,14 @@ paths:
- Users
/v1/users/filter:
get:
- description: Returns a list of dotCMS users based on specified search criteria
- with pagination support
+ description: "Returns a list of dotCMS users based on specified search criteria\
+ \ with pagination support. Each item is the user's data map. When `includeRoles=true`\
+ \ is passed, each item additionally carries a `roles` array listing the user's\
+ \ directly assigned roles as `{id, name, roleKey}` objects (`roleKey` may\
+ \ be null for roles created without a key); roles held through the role hierarchy\
+ \ and the user's personal role are not included. Requesting roles requires\
+ \ being a CMS Administrator or having access to both the Roles and Users portlets\
+ \ (403 otherwise). Without the flag the payload is unchanged."
operationId: filterUsers
parameters:
- description: "Filter users by user ID, first name, last name, email address,\
@@ -18993,6 +18999,15 @@ paths:
type: array
items:
type: string
+ - description: "When true, each user carries a `roles` array of its directly\
+ \ assigned roles as {id, name, roleKey}; inherited roles and the user's\
+ \ personal role are excluded. Requires CMS Administrator or Roles+Users\
+ \ portlet access (403 otherwise). Defaults to false (payload unchanged)"
+ in: query
+ name: includeRoles
+ schema:
+ type: boolean
+ default: false
responses:
"200":
content:
@@ -19012,7 +19027,8 @@ paths:
"403":
content:
application/json: {}
- description: Forbidden - insufficient permissions
+ description: "Forbidden - insufficient permissions, or includeRoles=true\
+ \ requested without CMS Administrator / Roles+Users portlet access"
summary: Filter users
tags:
- Users
diff --git a/dotCMS/src/test/java/com/dotcms/rest/api/v1/user/UserResourceTest.java b/dotCMS/src/test/java/com/dotcms/rest/api/v1/user/UserResourceTest.java
index 62be02995831..cda3eb673251 100644
--- a/dotCMS/src/test/java/com/dotcms/rest/api/v1/user/UserResourceTest.java
+++ b/dotCMS/src/test/java/com/dotcms/rest/api/v1/user/UserResourceTest.java
@@ -564,7 +564,7 @@ private UserResource getFilterTestResource(final WebResource webResource, final
/**
*
* - Method to test: {@link UserResource#filter(HttpServletRequest, HttpServletResponse, String, int,
- * int, String, String, boolean, boolean, String, int, List)}
+ * int, String, String, boolean, boolean, String, int, List, boolean)}
* - Given Scenario: Call the {@code /filter} endpoint passing {@code roleKey} values, both repeated
* and comma-separated.
* - Expected Result: Every key is resolved through the {@link RoleAPI} and the resulting Role list
@@ -593,7 +593,7 @@ public void testFilterWithRoleKeysAddsRolesToExtraParams() throws DotDataExcepti
final UserResource resource = getFilterTestResource(webResource, roleAPI, paginationUtil);
resource.filter(request, response, "jane", 0, 40, null, "ASC", false, false, null, 0,
- List.of("DOTCMS_BACK_END_USER,DOTCMS_FRONT_END_USER"));
+ List.of("DOTCMS_BACK_END_USER,DOTCMS_FRONT_END_USER"), false);
final ArgumentCaptor
* - Given Scenario: Call the {@code /filter} endpoint without any {@code roleKey} value.
* - Expected Result: The paginator extra params do NOT include {@link UserPaginator#ROLES_PARAM},
* preserving the endpoint's previous behavior.
@@ -624,18 +624,53 @@ public void testFilterWithoutRoleKeysLeavesRolesParamOut() throws DotDataExcepti
final PaginationUtil paginationUtil = mock(PaginationUtil.class);
final UserResource resource = getFilterTestResource(webResource, mock(RoleAPI.class), paginationUtil);
- resource.filter(request, response, "jane", 0, 40, null, "ASC", false, false, null, 0, null);
+ resource.filter(request, response, "jane", 0, 40, null, "ASC", false, false, null, 0, null, false);
final ArgumentCaptor