diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/permission/AbstractRolePermissionAssetView.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/permission/AbstractRolePermissionAssetView.java new file mode 100644 index 000000000000..4d0395ef522c --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/permission/AbstractRolePermissionAssetView.java @@ -0,0 +1,131 @@ +package com.dotcms.rest.api.v1.system.permission; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import io.swagger.v3.oas.annotations.media.Schema; +import org.immutables.value.Value; + +import java.util.List; +import java.util.Map; + +/** + * Represents a permission asset (host or folder) with role permissions. + * Contains asset metadata and permission levels for a specific role. + * + * @author hassandotcms + */ +@Value.Style(typeImmutable = "*", typeAbstract = "Abstract*") +@Value.Immutable +@JsonSerialize(as = RolePermissionAssetView.class) +@JsonDeserialize(as = RolePermissionAssetView.class) +@Schema(description = "Permission asset with role permission assignments") +public interface AbstractRolePermissionAssetView { + + /** + * Gets the asset identifier. + * + * @return Asset ID (host identifier for HOST type, folder inode for FOLDER type) + */ + @JsonProperty("id") + @Schema( + description = "Asset identifier (host identifier for HOST type, folder inode for FOLDER type)", + example = "abc-123-def-456", + requiredMode = Schema.RequiredMode.REQUIRED + ) + String id(); + + /** + * Gets the asset type. + * + * @return Asset type (HOST or FOLDER) + */ + @JsonProperty("type") + @Schema( + description = "Asset type", + allowableValues = {"HOST", "FOLDER"}, + example = "HOST", + requiredMode = Schema.RequiredMode.REQUIRED + ) + String type(); + + /** + * Gets the asset name. + * + * @return Asset name (hostname for HOST, folder name for FOLDER) + */ + @JsonProperty("name") + @Schema( + description = "Asset name (hostname for HOST, folder name for FOLDER)", + example = "demo.dotcms.com", + requiredMode = Schema.RequiredMode.REQUIRED + ) + String name(); + + /** + * Gets the full path to the asset. + * + * @return Asset path + */ + @JsonProperty("path") + @Schema( + description = "Full path to the asset", + example = "/demo.dotcms.com/application", + requiredMode = Schema.RequiredMode.REQUIRED + ) + String path(); + + /** + * Gets the host identifier. + * + * @return Host ID (same as id for HOST type, parent host for FOLDER type) + */ + @JsonProperty("hostId") + @Schema( + description = "Host identifier (same as id for HOST type, parent host for FOLDER type)", + example = "abc-123-def-456", + requiredMode = Schema.RequiredMode.REQUIRED + ) + String hostId(); + + /** + * Checks if the user can edit permissions on this asset. + * + * @return true if user can edit permissions, false otherwise + */ + @JsonProperty("canEditPermissions") + @Schema( + description = "Whether the requesting user can edit permissions on this asset", + example = "true", + requiredMode = Schema.RequiredMode.REQUIRED + ) + boolean canEditPermissions(); + + /** + * Checks if this asset inherits permissions from its parent. + * + * @return true if permissions are inherited, false otherwise + */ + @JsonProperty("inheritsPermissions") + @Schema( + description = "Whether this asset inherits permissions from its parent", + example = "false", + requiredMode = Schema.RequiredMode.REQUIRED + ) + boolean inheritsPermissions(); + + /** + * Gets the permission assignments for this asset and role. + * Map keys are permission scopes (INDIVIDUAL, CONTENT, FOLDER, HOST, etc.) + * and values are lists of permission level names (READ, WRITE, etc.). + * + * @return Permission map + */ + @JsonProperty("permissions") + @Schema( + description = "Map of permission scopes (INDIVIDUAL, CONTENT, FOLDER, HOST, etc.) to lists of permission level names (READ, WRITE, PUBLISH, EDIT_PERMISSIONS, CAN_ADD_CHILDREN)", + example = "{\"INDIVIDUAL\": [\"READ\", \"WRITE\", \"PUBLISH\"], \"CONTENT\": [\"READ\"]}", + requiredMode = Schema.RequiredMode.REQUIRED + ) + Map> permissions(); +} diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/permission/AbstractRolePermissionsView.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/permission/AbstractRolePermissionsView.java new file mode 100644 index 000000000000..66279d8c3606 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/permission/AbstractRolePermissionsView.java @@ -0,0 +1,63 @@ +package com.dotcms.rest.api.v1.system.permission; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import io.swagger.v3.oas.annotations.media.Schema; +import org.immutables.value.Value; + +import java.util.List; + +/** + * Response wrapper containing a role's permission assignments organized by assets. + * Includes role information and a list of permission assets (hosts and folders) + * where the role has access. + * + * @author dotCMS + * @since 24.01 + */ +@Value.Style(typeImmutable = "*", typeAbstract = "Abstract*") +@Value.Immutable +@JsonSerialize(as = RolePermissionsView.class) +@JsonDeserialize(as = RolePermissionsView.class) +@Schema(description = "Role permissions organized by assets") +public interface AbstractRolePermissionsView { + + /** + * Gets the role identifier. + * + * @return Role identifier + */ + @JsonProperty("roleId") + @Schema( + description = "Role identifier", + example = "abc-123-def-456", + requiredMode = Schema.RequiredMode.REQUIRED + ) + String roleId(); + + /** + * Gets the role display name. + * + * @return Role name + */ + @JsonProperty("roleName") + @Schema( + description = "Role display name", + example = "CMS Administrator", + requiredMode = Schema.RequiredMode.REQUIRED + ) + String roleName(); + + /** + * Gets the list of permission assets. + * + * @return List of permission assets (hosts and folders) + */ + @JsonProperty("assets") + @Schema( + description = "List of permission assets (hosts and folders) with their permission assignments", + requiredMode = Schema.RequiredMode.REQUIRED + ) + List assets(); +} diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/permission/AbstractUpdateRolePermissionsView.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/permission/AbstractUpdateRolePermissionsView.java new file mode 100644 index 000000000000..b302f0510af0 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/permission/AbstractUpdateRolePermissionsView.java @@ -0,0 +1,59 @@ +package com.dotcms.rest.api.v1.system.permission; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import io.swagger.v3.oas.annotations.media.Schema; +import org.immutables.value.Value; + +/** + * View for update role permissions operation result. + * Contains the result of updating permissions for a role on an asset. + * + * @author hassandotcms + */ +@Value.Style(typeImmutable = "*", typeAbstract = "Abstract*") +@Value.Immutable +@JsonSerialize(as = UpdateRolePermissionsView.class) +@JsonDeserialize(as = UpdateRolePermissionsView.class) +@Schema(description = "Result of updating role permissions on an asset") +public interface AbstractUpdateRolePermissionsView { + + /** + * Gets the role identifier. + * + * @return Role ID + */ + @JsonProperty("roleId") + @Schema( + description = "Role identifier", + example = "abc-123-def-456", + requiredMode = Schema.RequiredMode.REQUIRED + ) + String roleId(); + + /** + * Gets the role name. + * + * @return Role name + */ + @JsonProperty("roleName") + @Schema( + description = "Role name", + example = "Content Editor", + requiredMode = Schema.RequiredMode.REQUIRED + ) + String roleName(); + + /** + * Gets the updated asset with permissions. + * + * @return Role permission asset view + */ + @JsonProperty("asset") + @Schema( + description = "The updated asset with new permission assignments for this role", + requiredMode = Schema.RequiredMode.REQUIRED + ) + RolePermissionAssetView asset(); +} diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/permission/AssetPermissionHelper.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/permission/AssetPermissionHelper.java index 6d0d658abcbb..f1f39f21f0a2 100644 --- a/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/permission/AssetPermissionHelper.java +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/permission/AssetPermissionHelper.java @@ -28,6 +28,8 @@ import javax.inject.Named; import java.util.ArrayList; import java.util.EnumSet; +import java.util.Collections; +import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -386,6 +388,12 @@ private PermissionAPI.Scope getAssetTypeAsScope(final Permissionable asset) { return PermissionAPI.Scope.INDIVIDUAL; } + // Host.getPermissionType() returns Contentlet's type (inherited), + // but API should show HOST for user clarity + if (asset instanceof Host) { + return PermissionAPI.Scope.HOST; + } + return getScopeFromPermissionType(asset.getPermissionType()); } @@ -708,4 +716,356 @@ public ResetAssetPermissionsView resetAssetPermissions(final String assetId, fin .previousPermissionCount(previousPermissionCount) .build(); } + + // ======================================================================== + // UPDATE ROLE PERMISSIONS METHODS + // ======================================================================== + + /** + * Updates permissions for a specific role on an asset. + * Automatically breaks inheritance if the asset is currently inheriting. + * + *

Request semantics: + *

+ * + * @param roleId Role identifier + * @param assetId Asset identifier (Host ID, Host Name, or Folder ID) + * @param form Permission update form with scope-to-permissions map + * @param cascade If true, triggers async cascade job (query parameter) + * @param user Requesting user (must be admin) + * @return UpdateRolePermissionsView containing roleId, roleName, and asset with updated permissions + * @throws DotDataException If there's an error accessing data + * @throws DotSecurityException If security validation fails + */ + public UpdateRolePermissionsView updateRolePermissions(final String roleId, + final String assetId, + final UpdateRolePermissionsForm form, + final boolean cascade, + final User user) + throws DotDataException, DotSecurityException { + + Logger.debug(this, () -> String.format( + "updateRolePermissions - roleId: %s, assetId: %s, cascade: %s, user: %s", + roleId, assetId, cascade, user.getUserId())); + + // 1. Validate inputs + validateRolePermissionRequest(roleId, assetId, form); + + // 2. Load and validate role + final Role role = roleAPI.loadRoleById(roleId); + if (role == null) { + throw new NotFoundInDbException(String.format("Role not found: %s", roleId)); + } + + // 3. Resolve asset (Host ID, Host Name, or Folder ID) + final Permissionable asset = resolveHostOrFolder(assetId); + if (asset == null) { + throw new NotFoundInDbException(String.format("Asset not found: %s", assetId)); + } + + // 4. Check user has EDIT_PERMISSIONS on asset + final boolean canEditPermissions = permissionAPI.doesUserHavePermission( + asset, PermissionAPI.PERMISSION_EDIT_PERMISSIONS, user, false); + if (!canEditPermissions) { + throw new DotSecurityException(String.format( + "User does not have EDIT_PERMISSIONS permission on asset: %s", assetId)); + } + + final User systemUser = APILocator.getUserAPI().getSystemUser(); + + // 5. Break inheritance if currently inheriting (for this role only) + if (permissionAPI.isInheritingPermissions(asset)) { + Logger.debug(this, () -> String.format( + "Breaking permission inheritance for asset: %s, role: %s", assetId, roleId)); + final Permissionable parent = permissionAPI.findParentPermissionable(asset); + if (parent != null) { + permissionAPI.permissionIndividuallyByRole(parent, asset, systemUser, role); + } + } + + // 6. Build permissions from form (hybrid semantics: omit=preserve, empty=remove) + // Following RoleAjax pattern - no reading existing, just build from form + final List permissionsToSave = buildRolePermissionsFromForm(form, asset, role); + + // 7. Save just THIS role's permissions using the modern save() method + // save() upserts each permission by (inode, roleId, type) key, preserving: + // - Other roles' permissions (different roleId) + // - This role's scopes not in the form (different type) + if (!permissionsToSave.isEmpty()) { + permissionAPI.save(permissionsToSave, asset, systemUser, false); + } + + // 8. Handle cascade if requested and asset is a parent permissionable + if (cascade && asset.isParentPermissionable()) { + Logger.info(this, () -> String.format( + "Triggering cascade permissions job for asset: %s, role: %s", assetId, roleId)); + CascadePermissionsJob.triggerJobImmediately(asset, role); + } + + // 9. Build and return response + Logger.info(this, () -> String.format( + "Successfully updated permissions for role: %s on asset: %s", roleId, assetId)); + + return buildRolePermissionUpdateResponse(asset, role, user); + } + + /** + * Resolves an asset by Host ID, Host Name, or Folder ID. + * Tries Host first (by ID then by name), then Folder. + * + * @param assetId Asset identifier (Host ID, Host Name, or Folder ID) + * @return Permissionable asset (Host or Folder) or null if not found + * @throws DotDataException If there's an error accessing data + * @throws DotSecurityException If security validation fails + */ + private Permissionable resolveHostOrFolder(final String assetId) + throws DotDataException, DotSecurityException { + + if (!UtilMethods.isSet(assetId)) { + return null; + } + + final User systemUser = APILocator.getUserAPI().getSystemUser(); + final boolean respectFrontendRoles = false; + + Logger.debug(this, () -> String.format("Resolving host or folder by ID: %s", assetId)); + + // Try Host by ID first + try { + final Host host = hostAPI.find(assetId, systemUser, respectFrontendRoles); + if (host != null && UtilMethods.isSet(host.getIdentifier())) { + Logger.debug(this, () -> String.format("Resolved as Host by ID: %s", assetId)); + return host; + } + } catch (Exception e) { + Logger.debug(this, () -> String.format("Not a host by ID: %s", assetId)); + } + + // Try Host by name + try { + final Host host = hostAPI.findByName(assetId, systemUser, respectFrontendRoles); + if (host != null && UtilMethods.isSet(host.getIdentifier())) { + Logger.debug(this, () -> String.format("Resolved as Host by name: %s", assetId)); + return host; + } + } catch (Exception e) { + Logger.debug(this, () -> String.format("Not a host by name: %s", assetId)); + } + + // Try Folder + try { + final Folder folder = folderAPI.find(assetId, systemUser, respectFrontendRoles); + if (UtilMethods.isSet(() -> folder.getIdentifier())) { + Logger.debug(this, () -> String.format("Resolved as Folder: %s", assetId)); + return folder; + } + } catch (Exception e) { + Logger.debug(this, () -> String.format("Not a folder: %s", assetId)); + } + + Logger.warn(this, String.format("Unable to resolve host or folder: %s", assetId)); + return null; + } + + /** + * Validates the role permission update request. + * + * @param roleId Role identifier + * @param assetId Asset identifier + * @param form Permission update form + * @throws IllegalArgumentException If validation fails + */ + private void validateRolePermissionRequest(final String roleId, + final String assetId, + final UpdateRolePermissionsForm form) { + + if (!UtilMethods.isSet(roleId)) { + throw new IllegalArgumentException("Role ID is required"); + } + + if (!UtilMethods.isSet(assetId)) { + throw new IllegalArgumentException("Asset ID is required"); + } + + if (form == null || form.getPermissions() == null || form.getPermissions().isEmpty()) { + throw new IllegalArgumentException("permissions cannot be empty"); + } + + // Validate scopes and permission levels + for (final Map.Entry> entry : form.getPermissions().entrySet()) { + final String scope = entry.getKey(); + if (!PermissionConversionUtils.isValidScope(scope)) { + throw new IllegalArgumentException(String.format( + "Invalid permission scope: %s", scope)); + } + + final List levels = entry.getValue(); + if (levels != null) { + for (final String level : levels) { + if (!PermissionConversionUtils.isValidPermissionLevel(level)) { + throw new IllegalArgumentException(String.format( + "Invalid permission level '%s' in scope '%s'", level, scope)); + } + } + } + } + } + + /** + * Builds Permission objects from the role permission update form. + * + *

Follows the RoleAjax pattern - does NOT read existing permissions. + * Hybrid semantics work implicitly via assignPermissions() behavior: + *

    + *
  • Scopes in form with values → set those permissions
  • + *
  • Scopes in form with empty array → save bits=0 → triggers delete
  • + *
  • Scopes NOT in form → not in save list → preserved (untouched)
  • + *
+ * + * @param form Permission update form + * @param asset Target asset + * @param role Target role + * @return List of Permission objects to save + */ + private List buildRolePermissionsFromForm(final UpdateRolePermissionsForm form, + final Permissionable asset, + final Role role) { + + final List permissions = new ArrayList<>(); + final String assetPermissionId = asset.getPermissionId(); + final String roleId = role.getId(); + + // Process ONLY what's in the form - no reading existing! + // Scopes not in the form are preserved implicitly (assignPermissions doesn't delete them) + for (final Map.Entry> entry : form.getPermissions().entrySet()) { + final String scopeName = entry.getKey(); + final List scopePermissions = entry.getValue(); + final String permissionType = PermissionConversionUtils.convertScopeToPermissionType(scopeName); + + // Empty array = remove (bits=0 triggers delete in persistPermission) + // Non-empty = set those permissions + final int permissionBits = (scopePermissions == null || scopePermissions.isEmpty()) + ? 0 + : PermissionConversionUtils.convertPermissionNamesToBits(scopePermissions); + + permissions.add(new Permission( + permissionType, + assetPermissionId, + roleId, + permissionBits, + true // isBitPermission + )); + } + + return permissions; + } + + /** + * Builds the response view for the role permission update operation. + * + * @param asset Updated asset + * @param role Updated role + * @param user Requesting user + * @return UpdateRolePermissionsView with roleId, roleName, and asset data + * @throws DotDataException If there's an error accessing data + * @throws DotSecurityException If security validation fails + */ + private UpdateRolePermissionsView buildRolePermissionUpdateResponse(final Permissionable asset, + final Role role, + final User user) + throws DotDataException, DotSecurityException { + + // Build asset object with permissions for this role only + final RolePermissionAssetView assetView = buildAssetWithRolePermissions(asset, role, user); + + return UpdateRolePermissionsView.builder() + .roleId(role.getId()) + .roleName(role.getName()) + .asset(assetView) + .build(); + } + + /** + * Builds asset view with permissions filtered to a specific role. + * + * @param asset Target asset + * @param role Target role + * @param user Requesting user + * @return RolePermissionAssetView with id, type, name, path, hostId, and permissions + * @throws DotDataException If there's an error accessing data + * @throws DotSecurityException If security validation fails + */ + private RolePermissionAssetView buildAssetWithRolePermissions(final Permissionable asset, + final Role role, + final User user) + throws DotDataException, DotSecurityException { + + // Extract name, path, hostId based on asset type + final String name; + final String path; + final String hostId; + + if (asset instanceof Host) { + final Host host = (Host) asset; + name = host.getHostname(); + path = "/" + host.getHostname(); + hostId = host.getIdentifier(); + } else if (asset instanceof Folder) { + final Folder folder = (Folder) asset; + name = folder.getName(); + path = APILocator.getIdentifierAPI().find(folder.getIdentifier()).getPath(); + hostId = folder.getHostId(); + } else { + name = ""; + path = ""; + hostId = ""; + } + + // Permission metadata + final boolean canEditPermissions = permissionAPI.doesUserHavePermission( + asset, PermissionAPI.PERMISSION_EDIT_PERMISSIONS, user, false); + final boolean inheritsPermissions = permissionAPI.isInheritingPermissions(asset); + + // Get permissions for this role and build permission map + // We need BOTH individual permissions (INDIVIDUAL scope) AND inheritable permissions + // (CONTENT, FOLDER, etc.) stored on this asset. The getPermissions() method only returns + // individual permissions for this asset, so we also need getInheritablePermissions(). + final List individualPermissions = permissionAPI.getPermissions(asset, true); + final List inheritablePermissions = asset.isParentPermissionable() + ? permissionAPI.getInheritablePermissions(asset, true) + : Collections.emptyList(); + + // Combine both lists and filter to this role only + final List allPermissions = new ArrayList<>(individualPermissions); + allPermissions.addAll(inheritablePermissions); + final List rolePermissions = allPermissions.stream() + .filter(p -> p.getRoleId().equals(role.getId())) + .collect(Collectors.toList()); + + final Map> permissionMap = new LinkedHashMap<>(); + for (final Permission permission : rolePermissions) { + final String modernType = PermissionConversionUtils.getModernPermissionType(permission.getType()); + final List permissionNames = PermissionConversionUtils.convertBitsToPermissionNames( + permission.getPermission()); + if (!permissionNames.isEmpty()) { + permissionMap.put(modernType, permissionNames); + } + } + + return RolePermissionAssetView.builder() + .id(asset.getPermissionId()) + .type(getAssetTypeAsScope(asset).name()) + .name(name) + .path(path) + .hostId(hostId) + .canEditPermissions(canEditPermissions) + .inheritsPermissions(inheritsPermissions) + .permissions(permissionMap) + .build(); + } } diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/permission/PermissionResource.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/permission/PermissionResource.java index fc7e894dd47c..8e8aa4c42cb0 100644 --- a/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/permission/PermissionResource.java +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/permission/PermissionResource.java @@ -11,6 +11,7 @@ import com.dotcms.rest.api.v1.user.UserResourceHelper; import com.dotcms.rest.exception.BadRequestException; import com.dotmarketing.beans.Permission; +import com.dotcms.rest.exception.ForbiddenException; import com.dotmarketing.business.APILocator; import com.dotmarketing.business.PermissionAPI; import com.dotmarketing.business.Permissionable; @@ -930,4 +931,215 @@ public ResponseEntityResetPermissionsView resetAssetPermissions( return new ResponseEntityResetPermissionsView(result); } + + /** + * Retrieves all hosts and folders where a role has permissions defined, + * organized by asset with full permission matrices. + * + * @param request HTTP servlet request + * @param response HTTP servlet response + * @param roleId Role identifier + * @return ResponseEntityRolePermissionsView containing role info and permission assets + * @throws DotDataException If there's an error accessing permission data + * @throws DotSecurityException If security validation fails + */ + @Operation( + summary = "Get role permissions", + description = "Retrieves all hosts and folders where a role has permissions defined, " + + "organized by asset with full permission matrices. " + + "Admin users can view any role. Non-admin users can only view " + + "permissions for roles they belong to." + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", + description = "Role permissions retrieved successfully", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ResponseEntityRolePermissionsView.class))), + @ApiResponse(responseCode = "400", + description = "Bad request - invalid role id", + content = @Content(mediaType = "application/json")), + @ApiResponse(responseCode = "403", + description = "Forbidden - user does not have access to view this role's permissions", + content = @Content(mediaType = "application/json")) + }) + @GET + @Path("/role/{roleId}") + @JSONP + @NoCache + @Produces({MediaType.APPLICATION_JSON}) + public ResponseEntityRolePermissionsView getRolePermissions( + final @Context HttpServletRequest request, + final @Context HttpServletResponse response, + @Parameter(description = "Role identifier", required = true) + final @PathParam("roleId") String roleId) + throws DotDataException, DotSecurityException { + + Logger.debug(this, () -> String.format("getRolePermissions called - roleId: %s", roleId)); + + // Initialize request context with authentication + final User requestingUser = new WebResource.InitBuilder(webResource) + .requiredBackendUser(true) + .requiredFrontendUser(false) + .requestAndResponse(request, response) + .rejectWhenNoUser(true) + .init() + .getUser(); + + // Validate roleId + if (!UtilMethods.isSet(roleId)) { + Logger.warn(this, "Role ID is required but was not provided"); + throw new BadRequestException("Role ID is required"); + } + + // Load the role + final Role role = roleAPI.loadRoleById(roleId); + if (role == null) { + Logger.warn(this, String.format("Invalid role id: %s", roleId)); + throw new BadRequestException("Invalid role id: " + roleId); + } + + // Authorization check - admin OR user has this role + if (!requestingUser.isAdmin()) { + final boolean userHasRole = roleAPI.doesUserHaveRole(requestingUser, role); + if (!userHasRole) { + Logger.warn(this, String.format( + "User %s attempted to view permissions for role %s without having that role", + requestingUser.getUserId(), roleId)); + throw new ForbiddenException( + "User does not have access to view permissions for role: " + roleId); + } + } + + // Build permission response (reuse existing helper) + final List assets = permissionSaveHelper + .getUserPermissionAssets(role, requestingUser); + + // Build typed response + final RolePermissionsView rolePermissionsView = RolePermissionsView.builder() + .roleId(role.getId()) + .roleName(role.getName()) + .assets(assets) + .build(); + + Logger.info(this, () -> String.format( + "Successfully retrieved permissions for role %s (requested by %s)", + roleId, requestingUser.getUserId())); + + return new ResponseEntityRolePermissionsView(rolePermissionsView); + } + + /** + * Updates permissions for a specific role on an asset (host or folder). + * Automatically breaks permission inheritance if the asset currently inherits from parent. + * + *

Request semantics: + *

    + *
  • PUT replaces all permissions for this role on the asset
  • + *
  • Omitting a scope preserves existing permissions for that scope (hybrid model)
  • + *
  • Empty array [] removes permissions for that scope
  • + *
  • Implicit inheritance break if asset currently inherits
  • + *
+ * + * @param request HTTP servlet request + * @param response HTTP servlet response + * @param roleId Role identifier + * @param assetId Asset identifier (Host ID, Host Name, or Folder ID) + * @param cascade If true, triggers async job to cascade permissions to descendants + * @param form Permission update form with scope-to-permissions map + * @return ResponseEntityUpdateRolePermissionsView containing role info and updated asset + * @throws DotDataException If there's an error accessing permission data + * @throws DotSecurityException If security validation fails + */ + @Operation( + summary = "Update role permissions on asset", + description = "Updates permissions for a specific role on a host or folder. " + + "Automatically breaks permission inheritance if the asset currently inherits. " + + "Only admin users can access this endpoint. " + + "Omitting a scope preserves existing permissions; empty array removes permissions." + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", + description = "Permissions updated successfully", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ResponseEntityUpdateRolePermissionsView.class))), + @ApiResponse(responseCode = "400", + description = "Bad request - invalid permission level or scope", + content = @Content(mediaType = "application/json")), + @ApiResponse(responseCode = "401", + description = "Unauthorized - authentication required", + content = @Content(mediaType = "application/json")), + @ApiResponse(responseCode = "403", + description = "Forbidden - user lacks EDIT_PERMISSIONS on asset or is not admin", + content = @Content(mediaType = "application/json")), + @ApiResponse(responseCode = "404", + description = "Role or asset not found", + content = @Content(mediaType = "application/json")), + @ApiResponse(responseCode = "500", + description = "Failed to update permissions", + content = @Content(mediaType = "application/json")) + }) + @PUT + @Path("/role/{roleId}/asset/{assetId}") + @JSONP + @NoCache + @Produces({MediaType.APPLICATION_JSON}) + @Consumes({MediaType.APPLICATION_JSON}) + public ResponseEntityUpdateRolePermissionsView updateRolePermissions( + final @Context HttpServletRequest request, + final @Context HttpServletResponse response, + @Parameter(description = "Role identifier", required = true) + final @PathParam("roleId") String roleId, + @Parameter(description = "Asset identifier (Host ID, Host Name, or Folder ID)", required = true) + final @PathParam("assetId") String assetId, + @Parameter(description = "If true, cascades permissions to all child assets", required = false) + final @QueryParam("cascade") @DefaultValue("false") boolean cascade, + @RequestBody(description = "Permission data by scope", required = true, + content = @Content(schema = @Schema(implementation = UpdateRolePermissionsForm.class))) + final UpdateRolePermissionsForm form) + throws DotDataException, DotSecurityException { + + Logger.debug(this, () -> String.format( + "updateRolePermissions called - roleId: %s, assetId: %s, cascade: %s", + roleId, assetId, cascade)); + + // Validate parameters (matching updateUserPermissions pattern) + if (!UtilMethods.isSet(roleId)) { + throw new BadRequestException("Role ID is required"); + } + if (!UtilMethods.isSet(assetId)) { + throw new BadRequestException("Asset ID is required"); + } + if (form == null) { + throw new BadRequestException("Request body is required"); + } + + // Validate form data + form.checkValid(); + + // Initialize request context with authentication + final User user = new WebResource.InitBuilder(webResource) + .requiredBackendUser(true) + .requiredFrontendUser(false) + .requestAndResponse(request, response) + .rejectWhenNoUser(true) + .init() + .getUser(); + + // Verify user is admin + if (!user.isAdmin()) { + Logger.warn(this, String.format( + "Non-admin user %s attempted to update role permissions for role: %s on asset: %s", + user.getUserId(), roleId, assetId)); + throw new DotSecurityException("Only admin users can update role permissions"); + } + + // Delegate to helper for business logic + final UpdateRolePermissionsView result = assetPermissionHelper.updateRolePermissions( + roleId, assetId, form, cascade, user); + + Logger.info(this, () -> String.format( + "Successfully updated permissions for role: %s on asset: %s", roleId, assetId)); + + return new ResponseEntityUpdateRolePermissionsView(result); + } } diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/permission/PermissionSaveHelper.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/permission/PermissionSaveHelper.java index 9b08aeea1f11..2714931218ba 100644 --- a/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/permission/PermissionSaveHelper.java +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/permission/PermissionSaveHelper.java @@ -12,18 +12,9 @@ import com.dotmarketing.business.UserAPI; import com.dotmarketing.exception.DotDataException; import com.dotmarketing.exception.DotSecurityException; -import com.dotmarketing.portlets.categories.model.Category; -import com.dotmarketing.portlets.containers.model.Container; import com.dotmarketing.portlets.contentlet.business.HostAPI; -import com.dotmarketing.portlets.contentlet.model.Contentlet; import com.dotmarketing.portlets.folders.business.FolderAPI; import com.dotmarketing.portlets.folders.model.Folder; -import com.dotmarketing.portlets.htmlpageasset.model.IHTMLPage; -import com.dotmarketing.portlets.links.model.Link; -import com.dotmarketing.portlets.rules.model.Rule; -import com.dotmarketing.portlets.structure.model.Structure; -import com.dotmarketing.portlets.templates.design.bean.TemplateLayout; -import com.dotmarketing.portlets.templates.model.Template; import com.dotmarketing.quartz.job.CascadePermissionsJob; import com.dotmarketing.util.Logger; import com.dotmarketing.util.UtilMethods; @@ -31,13 +22,11 @@ import com.liferay.util.StringPool; import java.util.ArrayList; -import java.util.Collection; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; -import java.util.function.Function; import java.util.stream.Collectors; import javax.enterprise.context.ApplicationScoped; @@ -53,18 +42,6 @@ @ApplicationScoped public class PermissionSaveHelper { - /** - * Maps permission names to their corresponding permission bits. - * Uses functional approach for clean mapping of permission names to bit operations. - */ - private static final Map> PERMISSION_MAPPERS = Map.of( - "READ", bits -> bits | PermissionAPI.PERMISSION_READ, - "WRITE", bits -> bits | PermissionAPI.PERMISSION_WRITE, - "PUBLISH", bits -> bits | PermissionAPI.PERMISSION_PUBLISH, - "EDIT_PERMISSIONS", bits -> bits | PermissionAPI.PERMISSION_EDIT_PERMISSIONS, - "CAN_ADD_CHILDREN", bits -> bits | PermissionAPI.PERMISSION_CAN_ADD_CHILDREN - ); - private final PermissionAPI permissionAPI; private final HostAPI hostAPI; private final FolderAPI folderAPI; @@ -116,7 +93,7 @@ public Set getAvailablePermissionScopes() { * @return Set of permission level names */ public Set getAvailablePermissionLevels() { - return new HashSet<>(convertBitsToPermissionNames( + return new HashSet<>(PermissionConversionUtils.convertBitsToPermissionNames( PermissionAPI.PERMISSION_READ | PermissionAPI.PERMISSION_WRITE | PermissionAPI.PERMISSION_PUBLISH | @@ -125,78 +102,6 @@ public Set getAvailablePermissionLevels() { )); } - /** - * Converts permission bit mask to list of permission names. - * - * @param permissionBits The permission bit mask - * @return List of permission names (READ, WRITE, PUBLISH, EDIT_PERMISSIONS, CAN_ADD_CHILDREN) - */ - public List convertBitsToPermissionNames(final int permissionBits) { - final List permissions = new ArrayList<>(); - - if ((permissionBits & PermissionAPI.PERMISSION_READ) > 0) { - permissions.add("READ"); - } - if ((permissionBits & PermissionAPI.PERMISSION_WRITE) > 0) { - permissions.add("WRITE"); - } - if ((permissionBits & PermissionAPI.PERMISSION_PUBLISH) > 0) { - permissions.add("PUBLISH"); - } - if ((permissionBits & PermissionAPI.PERMISSION_EDIT_PERMISSIONS) > 0) { - permissions.add("EDIT_PERMISSIONS"); - } - if ((permissionBits & PermissionAPI.PERMISSION_CAN_ADD_CHILDREN) > 0) { - permissions.add("CAN_ADD_CHILDREN"); - } - - return permissions; - } - - /** - * Converts permission level names to permission bit mask. - * Inverse operation of convertBitsToPermissionNames(). - * - * @param permissionNames Collection of permission names (READ, WRITE, PUBLISH, EDIT_PERMISSIONS, CAN_ADD_CHILDREN) - * @return Combined permission bit mask - */ - public int convertPermissionNamesToBits(final Collection permissionNames) { - if (permissionNames == null || permissionNames.isEmpty()) { - return 0; - } - - int permissionBits = 0; - - for (final String permissionName : permissionNames) { - final String upperName = permissionName.toUpperCase(); - final Function mapper = PERMISSION_MAPPERS.get(upperName); - - if (mapper != null) { - permissionBits = mapper.apply(permissionBits); - } else { - Logger.warn(this, "Unknown permission name: " + permissionName); - } - } - - return permissionBits; - } - - /** - * Gets the Permission type string for a given scope name. - * Maps from REST API scope names (UPPERCASE) to Permission type class names. - * - * @param scopeName The scope name from API (UPPERCASE like "HOST", "FOLDER", "INDIVIDUAL") - * @return Permission type class name or INDIVIDUAL constant - */ - public String getPermissionTypeForScope(final String scopeName) { - final PermissionAPI.Scope scope = PermissionAPI.Scope.fromName(scopeName); - if (scope == null) { - Logger.warn(this, "Unknown permission scope: " + scopeName); - return scopeName; - } - return scope.getPermissionType(); - } - /** * Resolves asset (Host or Folder) from asset ID. * Replicates RoleAjax.saveRolePermission() logic (lines 815-820). @@ -293,7 +198,7 @@ public SaveUserPermissionsView saveUserPermissions( continue; } - final String permissionType = getPermissionTypeForScope(scope); + final String permissionType = PermissionConversionUtils.convertScopeToPermissionType(scope); final Permission permission = new Permission( permissionType, asset.getPermissionId(), @@ -417,9 +322,9 @@ private Map> buildPermissionMap(final List permi return permissions.stream() .collect(Collectors.groupingBy( - p -> getModernPermissionType(p.getType()), + p -> PermissionConversionUtils.getModernPermissionType(p.getType()), Collectors.mapping( - p -> convertBitsToPermissionNames(p.getPermission()), + p -> PermissionConversionUtils.convertBitsToPermissionNames(p.getPermission()), Collectors.flatMapping(List::stream, Collectors.toSet() ) @@ -427,18 +332,6 @@ private Map> buildPermissionMap(final List permi )); } - /** - * Maps permission type class names to API type constants. - */ - private String getModernPermissionType(final String permissionType) { - final PermissionAPI.Scope scope = PermissionAPI.Scope.fromPermissionType(permissionType); - if (scope != null) { - return scope.name(); - } - Logger.debug(this, "Unknown permission type: " + permissionType); - return permissionType.toUpperCase(); - } - // ========== GET User Permissions Methods ========== /** diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/permission/ResponseEntityRolePermissionsView.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/permission/ResponseEntityRolePermissionsView.java new file mode 100644 index 000000000000..86ba3d40d70a --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/permission/ResponseEntityRolePermissionsView.java @@ -0,0 +1,22 @@ +package com.dotcms.rest.api.v1.system.permission; + +import com.dotcms.rest.ResponseEntityView; + +/** + * Response wrapper for role permissions endpoint. + * Wraps the role permission data including roleId, roleName, and assets with permissions. + * + * @author dotCMS + * @since 24.01 + */ +public class ResponseEntityRolePermissionsView extends ResponseEntityView { + + /** + * Creates a new response entity view for role permissions. + * + * @param entity RolePermissionsView containing roleId, roleName, and assets + */ + public ResponseEntityRolePermissionsView(final RolePermissionsView entity) { + super(entity); + } +} diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/permission/ResponseEntityUpdateRolePermissionsView.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/permission/ResponseEntityUpdateRolePermissionsView.java new file mode 100644 index 000000000000..c2b853225581 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/permission/ResponseEntityUpdateRolePermissionsView.java @@ -0,0 +1,46 @@ +package com.dotcms.rest.api.v1.system.permission; + +import com.dotcms.rest.ResponseEntityView; + +/** + * Response entity view for PUT /api/v1/permissions/role/{roleId}/asset/{assetId} endpoint. + * + *

Response structure: + *

{@code
+ * {
+ *   "entity": {
+ *     "roleId": "role-abc-123",
+ *     "roleName": "Content Editors",
+ *     "asset": {
+ *       "id": "folder-xyz-456",
+ *       "type": "FOLDER",
+ *       "name": "News",
+ *       "path": "/demo.dotcms.com/content/news",
+ *       "hostId": "host-demo-123",
+ *       "canEditPermissions": true,
+ *       "inheritsPermissions": false,
+ *       "permissions": {
+ *         "INDIVIDUAL": ["READ", "WRITE", "PUBLISH"],
+ *         "FOLDER": ["READ", "WRITE"],
+ *         "CONTENT": ["READ", "WRITE", "PUBLISH"],
+ *         "PAGE": ["READ", "WRITE", "PUBLISH"]
+ *       }
+ *     }
+ *   }
+ * }
+ * }
+ * + * @author dotCMS + * @since 24.01 + */ +public class ResponseEntityUpdateRolePermissionsView extends ResponseEntityView { + + /** + * Creates a new ResponseEntityUpdateRolePermissionsView. + * + * @param entity UpdateRolePermissionsView containing roleId, roleName, and asset data with updated permissions + */ + public ResponseEntityUpdateRolePermissionsView(final UpdateRolePermissionsView entity) { + super(entity); + } +} diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/permission/UpdateRolePermissionsForm.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/permission/UpdateRolePermissionsForm.java new file mode 100644 index 000000000000..1e5ed6191277 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/permission/UpdateRolePermissionsForm.java @@ -0,0 +1,131 @@ +package com.dotcms.rest.api.v1.system.permission; + +import com.dotcms.rest.api.Validated; +import com.dotcms.rest.exception.BadRequestException; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; + +import javax.validation.constraints.NotNull; +import java.util.List; +import java.util.Map; + +/** + * Form for updating role permissions on an asset via PUT /api/v1/permissions/role/{roleId}/asset/{assetId}. + * + *

This form represents the request body for the role permissions update endpoint. + * The roleId comes from the path parameter, not from this form. + * The cascade parameter is passed as a query parameter, not in this form. + * + *

Request semantics: + *

    + *
  • PUT replaces all permissions for this role on the asset
  • + *
  • Omitting a scope preserves existing permissions for that scope (hybrid model)
  • + *
  • Empty array [] removes permissions for that scope
  • + *
  • Implicit inheritance break if asset currently inherits
  • + *
+ * + *

Example JSON: + *

{@code
+ * {
+ *   "permissions": {
+ *     "INDIVIDUAL": ["READ", "WRITE", "PUBLISH"],
+ *     "HOST": ["READ", "WRITE"],
+ *     "FOLDER": ["READ", "WRITE", "CAN_ADD_CHILDREN"],
+ *     "CONTENT": ["READ", "WRITE", "PUBLISH"],
+ *     "PAGE": ["READ", "WRITE", "PUBLISH"],
+ *     "CONTAINER": ["READ", "WRITE"],
+ *     "TEMPLATE": ["READ", "WRITE"],
+ *     "TEMPLATE_LAYOUT": ["READ"],
+ *     "LINK": ["READ", "WRITE"],
+ *     "CONTENT_TYPE": ["READ"],
+ *     "CATEGORY": ["READ", "CAN_ADD_CHILDREN"],
+ *     "RULE": ["READ"]
+ *   }
+ * }
+ * }
+ * + * @author dotCMS + * @since 24.01 + */ +public class UpdateRolePermissionsForm extends Validated { + + @JsonProperty("permissions") + @Schema( + description = "Permission assignments by scope (INDIVIDUAL, HOST, FOLDER, CONTENT, etc.) " + + "with permission levels (READ, WRITE, PUBLISH, EDIT_PERMISSIONS, CAN_ADD_CHILDREN). " + + "Omitting a scope preserves existing permissions. Empty array [] removes permissions for that scope.", + example = "{\"INDIVIDUAL\": [\"READ\", \"WRITE\"], \"CONTENT\": [\"READ\", \"PUBLISH\"]}", + required = true + ) + @NotNull(message = "permissions is required") + private final Map> permissions; + + /** + * Creates a new UpdateRolePermissionsForm. + * + * @param permissions Map of permission scopes to permission level arrays. + * Keys are scope names (INDIVIDUAL, FOLDER, CONTENT, etc.). + * Values are arrays of permission levels (READ, WRITE, PUBLISH, etc.). + */ + @JsonCreator + public UpdateRolePermissionsForm( + @JsonProperty("permissions") final Map> permissions) { + this.permissions = permissions; + } + + /** + * Gets the permission map. + * + * @return Map of scope names to permission level arrays + */ + public Map> getPermissions() { + return permissions; + } + + /** + * Validates the form data. + * + *

Validates: + *

    + *
  • permissions is not null or empty
  • + *
  • Each scope is a valid permission scope
  • + *
  • Each permission level is valid (empty arrays are allowed for removal)
  • + *
+ * + * @throws BadRequestException If validation fails + */ + @Override + public void checkValid() { + super.checkValid(); // JSR-303 validation + + if (permissions == null || permissions.isEmpty()) { + throw new BadRequestException("permissions cannot be empty"); + } + + // Validate scopes and permission levels + for (final Map.Entry> entry : permissions.entrySet()) { + final String scope = entry.getKey(); + if (!PermissionConversionUtils.isValidScope(scope)) { + throw new BadRequestException(String.format( + "Invalid permission scope: %s", scope)); + } + + final List levels = entry.getValue(); + // Note: Empty arrays are valid (they mean "remove permissions for this scope") + // but null values within the array are not valid + if (levels != null) { + for (final String level : levels) { + if (level == null) { + throw new BadRequestException(String.format( + "Permission level cannot be null in scope '%s'", scope)); + } + if (!PermissionConversionUtils.isValidPermissionLevel(level)) { + throw new BadRequestException(String.format( + "Invalid permission level '%s' in scope '%s'", level, scope)); + } + } + } + } + } +} diff --git a/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml b/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml index c2f01f0b1b01..6ffb32b034a5 100644 --- a/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml +++ b/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml @@ -10723,6 +10723,101 @@ paths: summary: Get permissions by permission type tags: - Permissions + /v1/permissions/role/{roleId}: + get: + description: "Retrieves all hosts and folders where a role has permissions defined,\ + \ organized by asset with full permission matrices. Admin users can view any\ + \ role. Non-admin users can only view permissions for roles they belong to." + operationId: getRolePermissions + parameters: + - description: Role identifier + in: path + name: roleId + required: true + schema: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/ResponseEntityRolePermissionsView" + description: Role permissions retrieved successfully + "400": + content: + application/json: {} + description: Bad request - invalid role id + "403": + content: + application/json: {} + description: Forbidden - user does not have access to view this role's permissions + summary: Get role permissions + tags: + - Permissions + /v1/permissions/role/{roleId}/asset/{assetId}: + put: + description: Updates permissions for a specific role on a host or folder. Automatically + breaks permission inheritance if the asset currently inherits. Only admin + users can access this endpoint. Omitting a scope preserves existing permissions; + empty array removes permissions. + operationId: updateRolePermissions + parameters: + - description: Role identifier + in: path + name: roleId + required: true + schema: + type: string + - description: "Asset identifier (Host ID, Host Name, or Folder ID)" + in: path + name: assetId + required: true + schema: + type: string + - description: "If true, cascades permissions to all child assets" + in: query + name: cascade + schema: + type: boolean + default: false + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateRolePermissionsForm" + description: Permission data by scope + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/ResponseEntityUpdateRolePermissionsView" + description: Permissions updated successfully + "400": + content: + application/json: {} + description: Bad request - invalid permission level or scope + "401": + content: + application/json: {} + description: Unauthorized - authentication required + "403": + content: + application/json: {} + description: Forbidden - user lacks EDIT_PERMISSIONS on asset or is not + admin + "404": + content: + application/json: {} + description: Role or asset not found + "500": + content: + application/json: {} + description: Failed to update permissions + summary: Update role permissions on asset + tags: + - Permissions /v1/permissions/user/{userId}: get: description: "Retrieves permissions for a user's individual role, organized\ @@ -22617,6 +22712,19 @@ components: type: string last: type: string + ImmutableListUserPermissionAssetView: + type: array + description: List of permission assets (hosts and folders) with their permission + assignments + items: + $ref: "#/components/schemas/UserPermissionAssetView" + properties: + empty: + type: boolean + first: + $ref: "#/components/schemas/UserPermissionAssetView" + last: + $ref: "#/components/schemas/UserPermissionAssetView" ImmutableListVariantResult: type: array items: @@ -22684,6 +22792,40 @@ components: properties: empty: type: boolean + ImmutableMapStringListString: + type: object + additionalProperties: + type: array + description: "Map of permission scopes (INDIVIDUAL, CONTENT, FOLDER, HOST,\ + \ etc.) to lists of permission level names (READ, WRITE, PUBLISH, EDIT_PERMISSIONS,\ + \ CAN_ADD_CHILDREN)" + example: + INDIVIDUAL: + - READ + - WRITE + - PUBLISH + CONTENT: + - READ + items: + type: string + description: "Map of permission scopes (INDIVIDUAL, CONTENT, FOLDER, HOST,\ + \ etc.) to lists of permission level names (READ, WRITE, PUBLISH, EDIT_PERMISSIONS,\ + \ CAN_ADD_CHILDREN)" + example: "{\"INDIVIDUAL\":[\"READ\",\"WRITE\",\"PUBLISH\"],\"CONTENT\":[\"\ + READ\"]}" + description: "Map of permission scopes (INDIVIDUAL, CONTENT, FOLDER, HOST, etc.)\ + \ to lists of permission level names (READ, WRITE, PUBLISH, EDIT_PERMISSIONS,\ + \ CAN_ADD_CHILDREN)" + example: + INDIVIDUAL: + - READ + - WRITE + - PUBLISH + CONTENT: + - READ + properties: + empty: + type: boolean ImmutableMapStringObject: type: object additionalProperties: @@ -25417,6 +25559,29 @@ components: type: array items: type: string + ResponseEntityRolePermissionsView: + type: object + properties: + entity: + $ref: "#/components/schemas/RolePermissionsView" + errors: + type: array + items: + $ref: "#/components/schemas/ErrorEntity" + i18nMessagesMap: + type: object + additionalProperties: + type: string + messages: + type: array + items: + $ref: "#/components/schemas/MessageEntity" + pagination: + $ref: "#/components/schemas/Pagination" + permissions: + type: array + items: + type: string ResponseEntityRoleViewListView: type: object properties: @@ -25682,6 +25847,29 @@ components: type: array items: type: string + ResponseEntityUpdateRolePermissionsView: + type: object + properties: + entity: + $ref: "#/components/schemas/UpdateRolePermissionsView" + errors: + type: array + items: + $ref: "#/components/schemas/ErrorEntity" + i18nMessagesMap: + type: object + additionalProperties: + type: string + messages: + type: array + items: + $ref: "#/components/schemas/MessageEntity" + pagination: + $ref: "#/components/schemas/Pagination" + permissions: + type: array + items: + type: string ResponseEntityUsageSummaryView: type: object properties: @@ -26674,6 +26862,86 @@ components: uniqueItems: true roleId: type: string + RolePermissionAssetView: + type: object + description: The updated asset with new permission assignments for this role + properties: + canEditPermissions: + type: boolean + description: Whether the requesting user can edit permissions on this asset + example: true + hostId: + type: string + description: "Host identifier (same as id for HOST type, parent host for\ + \ FOLDER type)" + example: abc-123-def-456 + id: + type: string + description: "Asset identifier (host identifier for HOST type, folder inode\ + \ for FOLDER type)" + example: abc-123-def-456 + inheritsPermissions: + type: boolean + description: Whether this asset inherits permissions from its parent + example: false + name: + type: string + description: "Asset name (hostname for HOST, folder name for FOLDER)" + example: demo.dotcms.com + path: + type: string + description: Full path to the asset + example: /demo.dotcms.com/application + permissions: + type: object + additionalProperties: + type: array + description: "Map of permission scopes (INDIVIDUAL, CONTENT, FOLDER, HOST,\ + \ etc.) to lists of permission level names (READ, WRITE, PUBLISH, EDIT_PERMISSIONS,\ + \ CAN_ADD_CHILDREN)" + example: + CONTENT: + - READ + INDIVIDUAL: + - READ + - WRITE + - PUBLISH + items: + type: string + description: "Map of permission scopes (INDIVIDUAL, CONTENT, FOLDER,\ + \ HOST, etc.) to lists of permission level names (READ, WRITE, PUBLISH,\ + \ EDIT_PERMISSIONS, CAN_ADD_CHILDREN)" + example: "{\"INDIVIDUAL\":[\"READ\",\"WRITE\",\"PUBLISH\"],\"CONTENT\"\ + :[\"READ\"]}" + description: "Map of permission scopes (INDIVIDUAL, CONTENT, FOLDER, HOST,\ + \ etc.) to lists of permission level names (READ, WRITE, PUBLISH, EDIT_PERMISSIONS,\ + \ CAN_ADD_CHILDREN)" + example: + CONTENT: + - READ + INDIVIDUAL: + - READ + - WRITE + - PUBLISH + properties: + empty: + type: boolean + type: + type: string + description: Asset type + enum: + - HOST + - FOLDER + example: HOST + required: + - canEditPermissions + - hostId + - id + - inheritsPermissions + - name + - path + - permissions + - type RolePermissionForm: type: object description: Permission assignment for a single role on an asset @@ -26831,6 +27099,34 @@ components: - inherited - roleId - roleName + RolePermissionsView: + type: object + properties: + assets: + type: array + description: List of permission assets (hosts and folders) with their permission + assignments + items: + $ref: "#/components/schemas/UserPermissionAssetView" + properties: + empty: + type: boolean + first: + $ref: "#/components/schemas/UserPermissionAssetView" + last: + $ref: "#/components/schemas/UserPermissionAssetView" + roleId: + type: string + description: Role identifier + example: abc-123-def-456 + roleName: + type: string + description: Role display name + example: CMS Administrator + required: + - assets + - roleId + - roleName RoleResponseEntityView: type: object properties: @@ -28227,6 +28523,62 @@ components: required: - assetPath - data + UpdateRolePermissionsForm: + type: object + properties: + permissions: + type: object + additionalProperties: + type: array + description: "Permission assignments by scope (INDIVIDUAL, HOST, FOLDER,\ + \ CONTENT, etc.) with permission levels (READ, WRITE, PUBLISH, EDIT_PERMISSIONS,\ + \ CAN_ADD_CHILDREN). Omitting a scope preserves existing permissions.\ + \ Empty array [] removes permissions for that scope." + example: + CONTENT: + - READ + - PUBLISH + INDIVIDUAL: + - READ + - WRITE + items: + type: string + description: "Permission assignments by scope (INDIVIDUAL, HOST, FOLDER,\ + \ CONTENT, etc.) with permission levels (READ, WRITE, PUBLISH, EDIT_PERMISSIONS,\ + \ CAN_ADD_CHILDREN). Omitting a scope preserves existing permissions.\ + \ Empty array [] removes permissions for that scope." + example: "{\"INDIVIDUAL\":[\"READ\",\"WRITE\"],\"CONTENT\":[\"READ\"\ + ,\"PUBLISH\"]}" + description: "Permission assignments by scope (INDIVIDUAL, HOST, FOLDER,\ + \ CONTENT, etc.) with permission levels (READ, WRITE, PUBLISH, EDIT_PERMISSIONS,\ + \ CAN_ADD_CHILDREN). Omitting a scope preserves existing permissions.\ + \ Empty array [] removes permissions for that scope." + example: + CONTENT: + - READ + - PUBLISH + INDIVIDUAL: + - READ + - WRITE + required: + - permissions + UpdateRolePermissionsView: + type: object + properties: + asset: + $ref: "#/components/schemas/RolePermissionAssetView" + roleId: + type: string + description: Role identifier + example: abc-123-def-456 + roleName: + type: string + description: Role name + example: Content Editor + required: + - asset + - roleId + - roleName UpdateTagForm: type: object properties: diff --git a/dotcms-integration/src/test/java/com/dotcms/rest/api/v1/system/permission/PermissionResourceIntegrationTest.java b/dotcms-integration/src/test/java/com/dotcms/rest/api/v1/system/permission/PermissionResourceIntegrationTest.java index 7bd0acd340c3..16c2263ba9c3 100644 --- a/dotcms-integration/src/test/java/com/dotcms/rest/api/v1/system/permission/PermissionResourceIntegrationTest.java +++ b/dotcms-integration/src/test/java/com/dotcms/rest/api/v1/system/permission/PermissionResourceIntegrationTest.java @@ -5,6 +5,7 @@ import com.dotcms.datagen.RoleDataGen; import com.dotcms.datagen.SiteDataGen; import com.dotcms.datagen.TestUserUtils; +import com.dotcms.datagen.UserDataGen; import com.dotcms.rest.ResponseEntityPaginatedDataView; import com.dotcms.rest.exception.ConflictException; import com.dotcms.mock.request.MockAttributeRequest; @@ -28,6 +29,7 @@ import com.liferay.util.Base64; import static org.junit.Assert.*; import java.util.ArrayList; +import java.util.Arrays; import java.util.EnumSet; import java.util.HashMap; import java.util.HashSet; @@ -173,6 +175,29 @@ private static HttpServletRequest mockRequest() { return request; } + /** + * Creates a mock HTTP request with custom credentials. + * + * @param email User email for authentication + * @param password User password for authentication + * @return Mock HTTP request with specified credentials + */ + private static HttpServletRequest getHttpRequest(final String email, final String password) { + final MockHeaderRequest request = new MockHeaderRequest( + new MockSessionRequest( + new MockAttributeRequest(new MockHttpRequestIntegrationTest(testHost.getHostname(), "/").request()) + .request()) + .request()); + + request.setHeader("Authorization", + "Basic " + new String(Base64.encode((email + ":" + password).getBytes()))); + + request.getSession().setAttribute(com.dotmarketing.util.WebKeys.CURRENT_HOST, testHost); + request.getSession().setAttribute(com.dotmarketing.util.WebKeys.CMS_SELECTED_HOST_ID, testHost.getIdentifier()); + + return request; + } + // ==================== PUT Permission Tests ==================== /** @@ -1164,4 +1189,227 @@ public void test_resetAssetPermissions_emptyAssetId_throws400() throws DotDataEx "" ); } + + // ======================================================================== + // GET ROLE PERMISSIONS TESTS + // ======================================================================== + + /** + * Method to test: getRolePermissions in the PermissionResource + * Given Scenario: Admin user requests permissions for any role + * ExpectedResult: Role permissions retrieved successfully with roleId, roleName, and assets + */ + @Test + public void test_getRolePermissions_adminCanViewAnyRole() throws DotDataException, DotSecurityException { + // Create a test role with some permissions + final Role testRole = new RoleDataGen().nextPersisted(); + + // Create a folder and add permissions for this role + final Folder testFolder = new FolderDataGen().site(testHost).nextPersisted(); + + final RolePermissionForm rolePermissionForm = new RolePermissionForm( + testRole.getId(), + EnumSet.of(PermissionAPI.Type.READ, PermissionAPI.Type.WRITE), + null + ); + final UpdateAssetPermissionsForm form = new UpdateAssetPermissionsForm( + Arrays.asList(rolePermissionForm) + ); + + // Set up permissions for the role + resource.updateAssetPermissions( + getHttpRequest(adminUser.getEmailAddress(), "admin"), + response, + testFolder.getInode(), + false, + form + ); + + // Call the endpoint as admin + final ResponseEntityRolePermissionsView responseView = resource.getRolePermissions( + getHttpRequest(adminUser.getEmailAddress(), "admin"), + response, + testRole.getId() + ); + + // Verify response + assertNotNull("Response should not be null", responseView); + final RolePermissionsView entity = responseView.getEntity(); + assertNotNull("Entity should not be null", entity); + + // Verify response fields + assertEquals("roleId should match", testRole.getId(), entity.roleId()); + assertEquals("roleName should match", testRole.getName(), entity.roleName()); + assertNotNull("assets should be present", entity.assets()); + + // Verify assets is a list + final List assets = entity.assets(); + assertNotNull("assets should be a list", assets); + } + + /** + * Method to test: getRolePermissions in the PermissionResource + * Given Scenario: Non-admin user requests permissions for their own role + * ExpectedResult: Role permissions retrieved successfully + */ + @Test + public void test_getRolePermissions_userCanViewOwnRole() throws DotDataException, DotSecurityException { + // Create a test role + final Role testRole = new RoleDataGen().nextPersisted(); + + // Create a non-admin user and assign them the test role + final String knownPassword = "testPassword789"; + final User testUser = new UserDataGen() + .password(knownPassword) + .roles(TestUserUtils.getFrontendRole(), TestUserUtils.getBackendRole(), testRole) + .nextPersisted(); + + // Call the endpoint - user viewing their own role + final ResponseEntityRolePermissionsView responseView = resource.getRolePermissions( + getHttpRequest(testUser.getEmailAddress(), knownPassword), + response, + testRole.getId() + ); + + // Verify response + assertNotNull("Response should not be null", responseView); + final RolePermissionsView entity = responseView.getEntity(); + assertNotNull("Entity should not be null", entity); + + // Verify response fields + assertEquals("roleId should match", testRole.getId(), entity.roleId()); + assertEquals("roleName should match", testRole.getName(), entity.roleName()); + assertNotNull("assets should be present", entity.assets()); + } + + /** + * Method to test: getRolePermissions in the PermissionResource + * Given Scenario: Non-admin user requests permissions for a role they don't have + * ExpectedResult: ForbiddenException is thrown (403) + */ + @Test(expected = com.dotcms.rest.exception.ForbiddenException.class) + public void test_getRolePermissions_userCannotViewOtherRole_throws403() throws DotDataException, DotSecurityException { + // Create a test role that the user will NOT have + final Role testRole = new RoleDataGen().nextPersisted(); + + // Create a non-admin user WITHOUT the test role + final String knownPassword = "testPassword101"; + final User testUser = new UserDataGen() + .password(knownPassword) + .roles(TestUserUtils.getFrontendRole(), TestUserUtils.getBackendRole()) + .nextPersisted(); + + // Call the endpoint - should throw ForbiddenException + resource.getRolePermissions( + getHttpRequest(testUser.getEmailAddress(), knownPassword), + response, + testRole.getId() + ); + } + + /** + * Method to test: getRolePermissions in the PermissionResource + * Given Scenario: Invalid role ID provided + * ExpectedResult: BadRequestException is thrown (400) + */ + @Test(expected = com.dotcms.rest.exception.BadRequestException.class) + public void test_getRolePermissions_invalidRoleId_throws400() throws DotDataException, DotSecurityException { + // Call with invalid role ID - should throw BadRequestException + resource.getRolePermissions( + getHttpRequest(adminUser.getEmailAddress(), "admin"), + response, + "invalid-role-id-99999" + ); + } + + /** + * Method to test: getRolePermissions in the PermissionResource + * Given Scenario: Empty role ID provided + * ExpectedResult: BadRequestException is thrown (400) + */ + @Test(expected = com.dotcms.rest.exception.BadRequestException.class) + public void test_getRolePermissions_emptyRoleId_throws400() throws DotDataException, DotSecurityException { + // Call with empty role ID - should throw BadRequestException + resource.getRolePermissions( + getHttpRequest(adminUser.getEmailAddress(), "admin"), + response, + "" + ); + } + + /** + * Method to test: getRolePermissions in the PermissionResource + * Given Scenario: Admin user requests permissions and verifies response structure + * ExpectedResult: Response contains correct structure with assets containing permissions map + */ + @Test + public void test_getRolePermissions_responseStructure() throws DotDataException, DotSecurityException { + // Create a test role + final Role testRole = new RoleDataGen().nextPersisted(); + + // Create a folder and add permissions for this role with inheritable permissions + final Folder testFolder = new FolderDataGen().site(testHost).nextPersisted(); + + final Map> inheritable = new HashMap<>(); + inheritable.put("FOLDER", EnumSet.of(PermissionAPI.Type.READ, PermissionAPI.Type.WRITE)); + inheritable.put("CONTENT", EnumSet.of(PermissionAPI.Type.READ, PermissionAPI.Type.WRITE, PermissionAPI.Type.PUBLISH)); + + final RolePermissionForm rolePermissionForm = new RolePermissionForm( + testRole.getId(), + EnumSet.of(PermissionAPI.Type.READ, PermissionAPI.Type.WRITE, PermissionAPI.Type.PUBLISH), + inheritable + ); + final UpdateAssetPermissionsForm form = new UpdateAssetPermissionsForm( + Arrays.asList(rolePermissionForm) + ); + + // Set up permissions for the role + resource.updateAssetPermissions( + getHttpRequest(adminUser.getEmailAddress(), "admin"), + response, + testFolder.getInode(), + false, + form + ); + + // Call the endpoint + final ResponseEntityRolePermissionsView responseView = resource.getRolePermissions( + getHttpRequest(adminUser.getEmailAddress(), "admin"), + response, + testRole.getId() + ); + + // Verify response structure + final RolePermissionsView entity = responseView.getEntity(); + assertEquals("roleId should match", testRole.getId(), entity.roleId()); + assertEquals("roleName should match", testRole.getName(), entity.roleName()); + + final List assets = entity.assets(); + assertNotNull("assets should be present", assets); + assertFalse("assets should not be empty", assets.isEmpty()); + + // Find the folder in the assets + boolean foundFolder = false; + for (UserPermissionAssetView asset : assets) { + if (testFolder.getInode().equals(asset.id())) { + foundFolder = true; + + // Verify asset structure + assertEquals("type should be FOLDER", "FOLDER", asset.type()); + assertNotNull("name should be present", asset.name()); + assertNotNull("path should be present", asset.path()); + assertNotNull("hostId should be present", asset.hostId()); + assertNotNull("canEditPermissions should be present", asset.canEditPermissions()); + assertNotNull("inheritsPermissions should be present", asset.inheritsPermissions()); + + // Verify permissions map + final Map> permissions = asset.permissions(); + assertNotNull("permissions should be present", permissions); + assertFalse("permissions should not be empty", permissions.isEmpty()); + + break; + } + } + assertTrue("Should find the test folder in assets", foundFolder); + } }