diff --git a/dotCMS/src/main/java/com/dotcms/business/APILocatorProducers.java b/dotCMS/src/main/java/com/dotcms/business/APILocatorProducers.java index 882b2bfe653b..9e6482e5e0f3 100644 --- a/dotCMS/src/main/java/com/dotcms/business/APILocatorProducers.java +++ b/dotCMS/src/main/java/com/dotcms/business/APILocatorProducers.java @@ -2,6 +2,7 @@ import com.dotmarketing.business.APILocator; import com.dotmarketing.business.PermissionAPI; +import com.dotmarketing.business.RoleAPI; import com.dotmarketing.business.UserAPI; import com.dotmarketing.portlets.contentlet.business.HostAPI; import com.dotmarketing.portlets.folders.business.FolderAPI; @@ -30,6 +31,11 @@ public PermissionAPI getPermissionAPI() { return APILocator.getPermissionAPI(); } + @Produces + public RoleAPI getRoleAPI() { + return APILocator.getRoleAPI(); + } + @Produces public FolderAPI getFolderAPI() { return APILocator.getFolderAPI(); diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/permission/AbstractAssetPermissionsView.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/permission/AbstractAssetPermissionsView.java new file mode 100644 index 000000000000..47da8e942e6d --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/permission/AbstractAssetPermissionsView.java @@ -0,0 +1,133 @@ +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 javax.annotation.Nullable; +import java.util.List; + +/** + * Immutable view representing an asset's permission data including metadata + * and a paginated list of role permissions. This is the entity returned by + * the GET /v1/permissions/{assetId} endpoint. + * + * @author hassandotcms + * @since 24.01 + */ +@Value.Style(typeImmutable = "*", typeAbstract = "Abstract*") +@Value.Immutable +@JsonSerialize(as = AssetPermissionsView.class) +@JsonDeserialize(as = AssetPermissionsView.class) +@Schema(description = "Asset permissions organized by roles with metadata") +public interface AbstractAssetPermissionsView { + + /** + * Gets the asset identifier. + * + * @return Asset ID (inode or identifier depending on asset type) + */ + @JsonProperty("assetId") + @Schema( + description = "Asset identifier", + example = "48190c8c-42c4-46af-8d1a-0cd5db894797", + requiredMode = Schema.RequiredMode.REQUIRED + ) + String assetId(); + + /** + * Gets the asset type. + * + * @return Asset type constant (HOST, FOLDER, CONTENT, TEMPLATE, CONTAINER, etc.) + */ + @JsonProperty("assetType") + @Schema( + description = "Asset type", + example = "FOLDER", + allowableValues = {"HOST", "FOLDER", "CONTENT", "TEMPLATE", "CONTAINER", "PAGE", "LINK", "CATEGORY", "RULE", "CONTENT_TYPE"}, + requiredMode = Schema.RequiredMode.REQUIRED + ) + String assetType(); + + /** + * Gets the permission inheritance mode. + * + * @return INHERITED if inheriting from parent, INDIVIDUAL if has own permissions + */ + @JsonProperty("inheritanceMode") + @Schema( + description = "Permission inheritance mode", + example = "INDIVIDUAL", + allowableValues = {"INHERITED", "INDIVIDUAL"}, + requiredMode = Schema.RequiredMode.REQUIRED + ) + String inheritanceMode(); + + /** + * Indicates if this asset can have child permissionables. + * Hosts and folders are typically parent permissionables. + * + * @return true if asset can have children with inheritable permissions + */ + @JsonProperty("isParentPermissionable") + @Schema( + description = "Whether this asset can have child permissionables (e.g., hosts and folders)", + example = "true", + requiredMode = Schema.RequiredMode.REQUIRED + ) + boolean isParentPermissionable(); + + /** + * Indicates if the requesting user can edit permissions on this asset. + * + * @return true if user has EDIT_PERMISSIONS permission + */ + @JsonProperty("canEditPermissions") + @Schema( + description = "Whether the requesting user can edit permissions on this asset", + example = "true", + requiredMode = Schema.RequiredMode.REQUIRED + ) + boolean canEditPermissions(); + + /** + * Indicates if the requesting user can edit this asset. + * + * @return true if user has WRITE permission + */ + @JsonProperty("canEdit") + @Schema( + description = "Whether the requesting user can edit this asset", + example = "true", + requiredMode = Schema.RequiredMode.REQUIRED + ) + boolean canEdit(); + + /** + * Gets the parent asset identifier if one exists. + * + * @return Parent asset ID, or null if no parent + */ + @JsonProperty("parentAssetId") + @Schema( + description = "Parent asset identifier (null if no parent or at root level)", + example = "abc-123-def-456" + ) + @Nullable + String parentAssetId(); + + /** + * Gets the paginated list of role permissions. + * Each entry represents a role and its permissions on this asset. + * + * @return List of role permission views + */ + @JsonProperty("permissions") + @Schema( + description = "Paginated list of role permissions assigned to this asset", + requiredMode = Schema.RequiredMode.REQUIRED + ) + List permissions(); +} diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/permission/AbstractRolePermissionView.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/permission/AbstractRolePermissionView.java new file mode 100644 index 000000000000..d67e4249262b --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/permission/AbstractRolePermissionView.java @@ -0,0 +1,98 @@ +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 javax.annotation.Nullable; +import java.util.List; +import java.util.Map; + +/** + * Immutable view representing a role's permission assignments on an asset. + * Each role can have individual permissions (directly on the asset) and + * inheritable permissions (propagated to child assets). + * + * @author hassandotcms + * @since 24.01 + */ +@Value.Style(typeImmutable = "*", typeAbstract = "Abstract*") +@Value.Immutable +@JsonSerialize(as = RolePermissionView.class) +@JsonDeserialize(as = RolePermissionView.class) +@Schema(description = "Role permission assignment for an asset") +public interface AbstractRolePermissionView { + + /** + * 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 display name. + * + * @return Role name + */ + @JsonProperty("roleName") + @Schema( + description = "Role display name", + example = "CMS Administrator", + requiredMode = Schema.RequiredMode.REQUIRED + ) + String roleName(); + + /** + * Indicates if these permissions are inherited from a parent asset. + * + * @return true if inherited, false if set directly on this asset + */ + @JsonProperty("inherited") + @Schema( + description = "Whether permissions are inherited from a parent asset", + example = "false", + requiredMode = Schema.RequiredMode.REQUIRED + ) + boolean inherited(); + + /** + * Gets the individual permission levels assigned to this role on the asset. + * These are permissions that apply directly to this asset. + * + * @return List of permission level names (READ, WRITE, PUBLISH, EDIT_PERMISSIONS, CAN_ADD_CHILDREN) + */ + @JsonProperty("individual") + @Schema( + description = "Individual permission levels assigned directly to this role on the asset", + example = "[\"READ\", \"WRITE\", \"PUBLISH\"]", + requiredMode = Schema.RequiredMode.REQUIRED + ) + List individual(); + + /** + * Gets the inheritable permissions grouped by scope. + * Only present for parent permissionables (hosts, folders). + * Map keys are permission scopes (HOST, FOLDER, CONTENT, TEMPLATE, etc.) + * and values are lists of permission level names. + * + * @return Map of scope to permission levels, or null if not a parent permissionable + */ + @JsonProperty("inheritable") + @Schema( + description = "Inheritable permissions by scope (only for parent permissionables). " + + "Keys are permission scopes (HOST, FOLDER, CONTENT, etc.), " + + "values are permission level names", + example = "{\"FOLDER\": [\"READ\", \"WRITE\"], \"CONTENT\": [\"READ\"]}" + ) + @Nullable + Map> inheritable(); +} diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/permission/AbstractUpdateAssetPermissionsView.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/permission/AbstractUpdateAssetPermissionsView.java new file mode 100644 index 000000000000..847938e675c5 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/permission/AbstractUpdateAssetPermissionsView.java @@ -0,0 +1,80 @@ +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; + +/** + * Immutable view for the update asset permissions operation result. + * Contains the result of saving permissions for multiple roles on an asset. + * + *

This view is returned by the PUT /api/v1/permissions/{assetId} endpoint + * and includes information about the operation (message, counts) plus the + * updated asset with its new permission assignments. + * + * @author dotCMS + * @since 24.01 + */ +@Value.Style(typeImmutable = "*", typeAbstract = "Abstract*") +@Value.Immutable +@JsonSerialize(as = UpdateAssetPermissionsView.class) +@JsonDeserialize(as = UpdateAssetPermissionsView.class) +@Schema(description = "Result of updating asset permissions") +public interface AbstractUpdateAssetPermissionsView { + + /** + * Gets the success message. + * + * @return Success message describing the operation result + */ + @JsonProperty("message") + @Schema( + description = "Success message describing the operation result", + example = "Permissions saved successfully", + requiredMode = Schema.RequiredMode.REQUIRED + ) + String message(); + + /** + * Gets the number of permissions saved. + * + * @return Count of permission entries saved + */ + @JsonProperty("permissionCount") + @Schema( + description = "Number of permission entries saved during this operation", + example = "5", + requiredMode = Schema.RequiredMode.REQUIRED + ) + int permissionCount(); + + /** + * Indicates if inheritance was broken during this operation. + * When saving permissions on an asset that was inheriting from its parent, + * inheritance is automatically broken before saving. + * + * @return true if inheritance was broken, false if asset already had individual permissions + */ + @JsonProperty("inheritanceBroken") + @Schema( + description = "Whether permission inheritance was broken during this operation. " + + "True if the asset was previously inheriting permissions from its parent.", + example = "true", + requiredMode = Schema.RequiredMode.REQUIRED + ) + boolean inheritanceBroken(); + + /** + * Gets the updated asset with its new permission assignments. + * + * @return Asset permissions view with metadata and role permissions + */ + @JsonProperty("asset") + @Schema( + description = "The updated asset with its new permission assignments", + requiredMode = Schema.RequiredMode.REQUIRED + ) + AssetPermissionsView 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 new file mode 100644 index 000000000000..d3fc425f7865 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/permission/AssetPermissionHelper.java @@ -0,0 +1,691 @@ +package com.dotcms.rest.api.v1.system.permission; + +import com.dotcms.contenttype.exception.NotFoundInDbException; +import com.dotmarketing.beans.Host; +import com.dotmarketing.beans.Permission; +import com.dotmarketing.business.APILocator; +import com.dotmarketing.business.PermissionAPI; +import com.dotmarketing.business.Permissionable; +import com.dotmarketing.business.Role; +import com.dotmarketing.business.RoleAPI; +import com.dotmarketing.exception.DotDataException; +import com.dotmarketing.exception.DotSecurityException; +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.templates.model.Template; +import com.dotmarketing.quartz.job.CascadePermissionsJob; +import com.dotmarketing.util.Logger; +import com.dotmarketing.util.UtilMethods; +import com.google.common.annotations.VisibleForTesting; +import com.liferay.portal.model.User; +import com.liferay.util.StringPool; + +import javax.enterprise.context.ApplicationScoped; +import javax.inject.Inject; +import javax.inject.Named; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * Helper for building asset-centric permission responses for REST endpoints. + * Provides permission data transformation for the View Asset Permissions API. + * + * @author hassandotcms + * @since 24.01 + */ +@ApplicationScoped +public class AssetPermissionHelper { + + private final PermissionAPI permissionAPI; + private final RoleAPI roleAPI; + private final HostAPI hostAPI; + private final FolderAPI folderAPI; + + /** + * Default constructor for CDI. + */ + public AssetPermissionHelper() { + this(APILocator.getPermissionAPI(), + APILocator.getRoleAPI(), + APILocator.getHostAPI(), + APILocator.getFolderAPI()); + } + + /** + * Constructor with dependency injection for testing and CDI. + * + * @param permissionAPI Permission API for permission operations + * @param roleAPI Role API for role lookups + * @param hostAPI Host API for host lookups + * @param folderAPI Folder API for folder lookups + */ + @Inject + public AssetPermissionHelper(final PermissionAPI permissionAPI, + final RoleAPI roleAPI, + @Named("HostAPI") final HostAPI hostAPI, + final FolderAPI folderAPI) { + this.permissionAPI = permissionAPI; + this.roleAPI = roleAPI; + this.hostAPI = hostAPI; + this.folderAPI = folderAPI; + } + + /** + * Resolves an asset by ID, trying multiple asset types. + * + * @param assetId Asset identifier (inode or identifier) + * @return Permissionable asset or null if not found + * @throws DotDataException If there's an error accessing data + * @throws DotSecurityException If security validation fails + */ + public Permissionable resolveAsset(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 asset by ID: %s", assetId)); + + // Try Folder (most common for permissions) + 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)); + } + + // Try Host + try { + final Host host = hostAPI.find(assetId, systemUser, respectFrontendRoles); + if (host != null && UtilMethods.isSet(host.getIdentifier())) { + Logger.debug(this, () -> String.format("Resolved as Host: %s", assetId)); + return host; + } + } catch (Exception e) { + Logger.debug(this, () -> String.format("Not a host: %s", assetId)); + } + + // Try Contentlet + try { + final Contentlet contentlet = APILocator.getContentletAPI() + .findContentletByIdentifierAnyLanguage(assetId); + if (contentlet != null && UtilMethods.isSet(contentlet.getIdentifier())) { + Logger.debug(this, () -> String.format("Resolved as Contentlet: %s", assetId)); + return contentlet; + } + } catch (Exception e) { + Logger.debug(this, () -> String.format("Not a contentlet: %s", assetId)); + } + + // Try Template + try { + final Template template = APILocator.getTemplateAPI() + .findWorkingTemplate(assetId, systemUser, respectFrontendRoles); + if (template != null && UtilMethods.isSet(template.getIdentifier())) { + Logger.debug(this, () -> String.format("Resolved as Template: %s", assetId)); + return template; + } + } catch (Exception e) { + Logger.debug(this, () -> String.format("Not a template: %s", assetId)); + } + + // Try Container + try { + final Container container = APILocator.getContainerAPI() + .getWorkingContainerById(assetId, systemUser, respectFrontendRoles); + if (container != null && UtilMethods.isSet(container.getIdentifier())) { + Logger.debug(this, () -> String.format("Resolved as Container: %s", assetId)); + return container; + } + } catch (Exception e) { + Logger.debug(this, () -> String.format("Not a container: %s", assetId)); + } + + Logger.warn(this, String.format("Unable to resolve asset: %s", assetId)); + return null; + } + + /** + * Builds asset metadata for constructing the response view. + * + * @param asset The permissionable asset + * @param requestingUser User making the request + * @return AssetMetadata containing all metadata fields + * @throws DotDataException If there's an error accessing data + * @throws DotSecurityException If security validation fails + */ + public AssetMetadata getAssetMetadata(final Permissionable asset, + final User requestingUser) + throws DotDataException, DotSecurityException { + + final boolean canEditPermissions = permissionAPI.doesUserHavePermission( + asset, PermissionAPI.PERMISSION_EDIT_PERMISSIONS, requestingUser, false); + + final boolean canEdit = permissionAPI.doesUserHavePermission( + asset, PermissionAPI.PERMISSION_WRITE, requestingUser, false); + + final boolean isInheriting = permissionAPI.isInheritingPermissions(asset); + + String parentAssetId = null; + try { + final Permissionable parent = permissionAPI.findParentPermissionable(asset); + if (parent != null) { + parentAssetId = parent.getPermissionId(); + } + } catch (Exception e) { + Logger.debug(this, () -> "No parent permissionable found for asset"); + } + + return new AssetMetadata( + asset.getPermissionId(), + getAssetType(asset), + isInheriting ? "INHERITED" : "INDIVIDUAL", + asset.isParentPermissionable(), + canEditPermissions, + canEdit, + parentAssetId + ); + } + + /** + * Builds permission data grouped by role, returning typed immutable views. + * + * @param asset The permissionable asset + * @param requestingUser User making the request + * @return List of RolePermissionView objects + * @throws DotDataException If there's an error accessing data + */ + public List buildRolePermissions(final Permissionable asset, + final User requestingUser) + throws DotDataException { + + final List permissions = permissionAPI.getPermissions(asset, true); + + if (permissions == null || permissions.isEmpty()) { + Logger.debug(this, () -> "No permissions found for asset"); + return new ArrayList<>(); + } + + final boolean isInheriting = permissionAPI.isInheritingPermissions(asset); + final boolean isParentPermissionable = asset.isParentPermissionable(); + + // Group permissions by role ID + final Map> permissionsByRole = permissions.stream() + .collect(Collectors.groupingBy(Permission::getRoleId, LinkedHashMap::new, Collectors.toList())); + + final List rolePermissions = new ArrayList<>(); + + for (final Map.Entry> entry : permissionsByRole.entrySet()) { + final String roleId = entry.getKey(); + final List rolePermissionList = entry.getValue(); + + try { + final Role role = roleAPI.loadRoleById(roleId); + if (role == null) { + Logger.warn(this, String.format("Role not found: %s", roleId)); + continue; + } + + // Separate individual and inheritable permissions + final List individualPermissions = rolePermissionList.stream() + .filter(Permission::isIndividualPermission) + .collect(Collectors.toList()); + + final List inheritablePermissions = rolePermissionList.stream() + .filter(p -> !p.isIndividualPermission()) + .collect(Collectors.toList()); + + // Build individual permissions array + final List individual = convertPermissionsToStringArray(individualPermissions); + + // Build inheritable permissions map (only for parent permissionables) + final Map> inheritable; + if (isParentPermissionable && !inheritablePermissions.isEmpty()) { + inheritable = buildInheritablePermissionMap(inheritablePermissions); + } else { + inheritable = null; + } + + final RolePermissionView rolePermissionView = RolePermissionView.builder() + .roleId(roleId) + .roleName(role.getName()) + .inherited(isInheriting) + .individual(individual) + .inheritable(inheritable) + .build(); + + rolePermissions.add(rolePermissionView); + + } catch (DotDataException e) { + Logger.warn(this, String.format("Error loading role: %s - %s", + roleId, e.getMessage())); + } + } + + return rolePermissions; + } + + /** + * Asset metadata holder for constructing the response view. + */ + public static class AssetMetadata { + private final String assetId; + private final String assetType; + private final String inheritanceMode; + private final boolean isParentPermissionable; + private final boolean canEditPermissions; + private final boolean canEdit; + private final String parentAssetId; + + public AssetMetadata(final String assetId, + final String assetType, + final String inheritanceMode, + final boolean isParentPermissionable, + final boolean canEditPermissions, + final boolean canEdit, + final String parentAssetId) { + this.assetId = assetId; + this.assetType = assetType; + this.inheritanceMode = inheritanceMode; + this.isParentPermissionable = isParentPermissionable; + this.canEditPermissions = canEditPermissions; + this.canEdit = canEdit; + this.parentAssetId = parentAssetId; + } + + public String assetId() { return assetId; } + public String assetType() { return assetType; } + public String inheritanceMode() { return inheritanceMode; } + public boolean isParentPermissionable() { return isParentPermissionable; } + public boolean canEditPermissions() { return canEditPermissions; } + public boolean canEdit() { return canEdit; } + public String parentAssetId() { return parentAssetId; } + } + + /** + * Converts a list of permissions to an array of permission level strings. + * Handles bit-packed permissions correctly. + * + * @param permissions List of permissions with same scope + * @return List of permission level strings (e.g., ["READ", "WRITE"]) + */ + private List convertPermissionsToStringArray(final List permissions) { + if (permissions == null || permissions.isEmpty()) { + return new ArrayList<>(); + } + + // Combine all permission bits + int combinedBits = 0; + for (final Permission permission : permissions) { + combinedBits |= permission.getPermission(); + } + + return convertBitsToPermissionNames(combinedBits); + } + + /** + * Builds inheritable permission map grouped by scope. + * + * @param inheritablePermissions List of inheritable permissions + * @return Map of scope to permission level arrays + */ + private Map> buildInheritablePermissionMap( + final List inheritablePermissions) { + + if (inheritablePermissions == null || inheritablePermissions.isEmpty()) { + return new HashMap<>(); + } + + return inheritablePermissions.stream() + .collect(Collectors.groupingBy( + p -> getModernPermissionType(p.getType()), + Collectors.collectingAndThen( + Collectors.toList(), + this::convertPermissionsToStringArray + ) + )); + } + + /** + * Converts permission bits to permission level names. + * Delegates to {@link PermissionConversionUtils#convertBitsToPermissionNames(int)}. + * + * @param permissionBits Bit-packed permission value + * @return List of permission level strings + */ + private List convertBitsToPermissionNames(final int permissionBits) { + return PermissionConversionUtils.convertBitsToPermissionNames(permissionBits); + } + + /** + * Gets the modern API type name for a permission type. + * Delegates to {@link PermissionConversionUtils#getModernPermissionType(String)}. + * + * @param permissionType Internal permission type (class name or scope) + * @return Modern API type constant + */ + private String getModernPermissionType(final String permissionType) { + return PermissionConversionUtils.getModernPermissionType(permissionType); + } + + /** + * Gets the asset type string for the response. + * Maps Permissionable types to API type constants (uppercase enum). + * + *

Uses the actual class name for lookup first, which correctly handles + * cases like Host (which extends Contentlet but should return "HOST"). + * + * @param asset The permissionable asset + * @return Asset type enum constant (e.g., "FOLDER", "HOST", "CONTENT") + */ + private String getAssetType(final Permissionable asset) { + if (asset == null) { + return StringPool.BLANK; + } + + // First try to determine type from actual class name + // This correctly handles Host (extends Contentlet) returning "HOST" not "CONTENT" + final String className = asset.getClass().getCanonicalName(); + final String classBasedType = PermissionConversionUtils.getModernPermissionType(className); + + // If we found a mapped type (not just the uppercase class name), use it + if (UtilMethods.isSet(classBasedType) && !classBasedType.equalsIgnoreCase(className)) { + return classBasedType; + } + + // Fall back to permission type for unmapped classes + final String permissionType = asset.getPermissionType(); + return getModernPermissionType(permissionType); + } + + // ======================================================================== + // UPDATE ASSET PERMISSIONS METHODS + // ======================================================================== + + /** + * Updates permissions for an asset based on the provided form. + * Automatically breaks inheritance if the asset is currently inheriting. + * + * @param assetId Asset identifier (inode or identifier) + * @param form Permission update form with role permissions + * @param cascade If true, triggers async cascade job (query parameter) + * @param user Requesting user (must be admin) + * @return UpdateAssetPermissionsView containing message, permissionCount, inheritanceBroken, and asset + * @throws DotDataException If there's an error accessing data + * @throws DotSecurityException If security validation fails + */ + public UpdateAssetPermissionsView updateAssetPermissions(final String assetId, + final UpdateAssetPermissionsForm form, + final boolean cascade, + final User user) + throws DotDataException, DotSecurityException { + + Logger.debug(this, () -> String.format( + "updateAssetPermissions - assetId: %s, cascade: %s, user: %s", + assetId, cascade, user.getUserId())); + + // 1. Validate request + validateUpdateRequest(assetId, form); + + // 2. Resolve asset + final Permissionable asset = resolveAsset(assetId); + if (asset == null) { + throw new NotFoundInDbException("asset does not exist"); + } + + // 3. 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)); + } + + // 4. Check if asset is currently inheriting (for response flag) + final boolean wasInheriting = permissionAPI.isInheritingPermissions(asset); + + // 5. Break inheritance if currently inheriting + if (wasInheriting) { + Logger.debug(this, () -> String.format( + "Breaking permission inheritance for asset: %s", assetId)); + final Permissionable parent = permissionAPI.findParentPermissionable(asset); + if (parent != null) { + permissionAPI.permissionIndividually(parent, asset, user); + } + } + + // 6. Build Permission objects from form + final List permissionsToSave = buildPermissionsFromForm(form, asset); + + // 7. Save permissions + if (!permissionsToSave.isEmpty()) { + permissionAPI.save(permissionsToSave, asset, user, 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", assetId)); + // Trigger cascade for each role in the form + for (final RolePermissionForm roleForm : form.getPermissions()) { + try { + final Role role = roleAPI.loadRoleById(roleForm.getRoleId()); + if (role != null) { + CascadePermissionsJob.triggerJobImmediately(asset, role); + } + } catch (Exception e) { + Logger.warn(this, String.format( + "Failed to trigger cascade for role %s: %s", + roleForm.getRoleId(), e.getMessage())); + } + } + } + + // 9. Build and return response + Logger.info(this, () -> String.format( + "Successfully updated permissions for asset: %s", assetId)); + + return buildUpdateResponse(asset, user, wasInheriting, permissionsToSave.size()); + } + + /** + * Validates the update request form and asset ID. + * + * @param assetId Asset identifier + * @param form Permission update form + * @throws IllegalArgumentException If validation fails + * @throws DotDataException If role lookup fails + */ + private void validateUpdateRequest(final String assetId, + final UpdateAssetPermissionsForm form) + throws DotDataException { + + if (!UtilMethods.isSet(assetId)) { + throw new IllegalArgumentException("Asset ID is required"); + } + + if (form == null || form.getPermissions() == null || form.getPermissions().isEmpty()) { + throw new IllegalArgumentException("permissions list is required"); + } + + for (final RolePermissionForm roleForm : form.getPermissions()) { + // Validate role ID is provided + if (!UtilMethods.isSet(roleForm.getRoleId())) { + throw new IllegalArgumentException("roleId is required for each permission entry"); + } + + // Validate role exists + final Role role = roleAPI.loadRoleById(roleForm.getRoleId()); + if (role == null) { + throw new IllegalArgumentException(String.format( + "Invalid role id: %s", roleForm.getRoleId())); + } + + // Validate individual permission names + if (roleForm.getIndividual() != null) { + for (final String perm : roleForm.getIndividual()) { + if (!PermissionConversionUtils.isValidPermissionLevel(perm)) { + throw new IllegalArgumentException(String.format( + "Invalid permission level: %s", perm)); + } + } + } + + // Validate inheritable permission names and scopes + if (roleForm.getInheritable() != null) { + for (final Map.Entry> entry : roleForm.getInheritable().entrySet()) { + final String scope = entry.getKey(); + if (!PermissionConversionUtils.isValidScope(scope)) { + throw new IllegalArgumentException(String.format( + "Invalid permission scope: %s", scope)); + } + + if (entry.getValue() != null) { + for (final String perm : entry.getValue()) { + if (!PermissionConversionUtils.isValidPermissionLevel(perm)) { + throw new IllegalArgumentException(String.format( + "Invalid permission level '%s' in scope '%s'", perm, scope)); + } + } + } + } + } + } + } + + /** + * Builds Permission objects from the update form. + * + * @param form Permission update form + * @param asset Target asset + * @return List of Permission objects to save + */ + private List buildPermissionsFromForm(final UpdateAssetPermissionsForm form, + final Permissionable asset) { + + final List permissions = new ArrayList<>(); + final String assetPermissionId = asset.getPermissionId(); + + for (final RolePermissionForm roleForm : form.getPermissions()) { + final String roleId = roleForm.getRoleId(); + + // Build individual permissions + if (roleForm.getIndividual() != null && !roleForm.getIndividual().isEmpty()) { + final int permissionBits = convertPermissionNamesToBits(roleForm.getIndividual()); + permissions.add(new Permission( + PermissionAPI.INDIVIDUAL_PERMISSION_TYPE, + assetPermissionId, + roleId, + permissionBits, + true + )); + } + + // Build inheritable permissions (only for parent permissionables) + if (asset.isParentPermissionable() && roleForm.getInheritable() != null) { + for (final Map.Entry> entry : roleForm.getInheritable().entrySet()) { + final String scopeName = entry.getKey(); + final List scopePermissions = entry.getValue(); + + if (scopePermissions == null || scopePermissions.isEmpty()) { + continue; + } + + final String permissionType = convertScopeToPermissionType(scopeName); + final int permissionBits = convertPermissionNamesToBits(scopePermissions); + + permissions.add(new Permission( + permissionType, + assetPermissionId, + roleId, + permissionBits, + true + )); + } + } + } + + return permissions; + } + + /** + * Converts permission level names to a bitwise permission value. + * Delegates to {@link PermissionConversionUtils#convertPermissionNamesToBits(List)}. + * + * @param permissionNames List of permission level names (READ, WRITE, etc.) + * @return Combined bit value + */ + private int convertPermissionNamesToBits(final List permissionNames) { + return PermissionConversionUtils.convertPermissionNamesToBits(permissionNames); + } + + /** + * Converts an API scope name to internal permission type. + * Delegates to {@link PermissionConversionUtils#convertScopeToPermissionType(String)}. + * + * @param scopeName API scope name (FOLDER, CONTENT, etc.) + * @return Internal permission type (class canonical name) + * @throws IllegalArgumentException If scope is unknown + */ + private String convertScopeToPermissionType(final String scopeName) { + return PermissionConversionUtils.convertScopeToPermissionType(scopeName); + } + + /** + * Builds the typed response view for the update operation. + * + * @param asset Updated asset + * @param user Requesting user + * @param inheritanceBroken Whether inheritance was broken during this operation + * @param permissionCount Number of permissions saved + * @return UpdateAssetPermissionsView with message, permissionCount, inheritanceBroken, and asset + * @throws DotDataException If there's an error accessing data + * @throws DotSecurityException If security validation fails + */ + private UpdateAssetPermissionsView buildUpdateResponse(final Permissionable asset, + final User user, + final boolean inheritanceBroken, + final int permissionCount) + throws DotDataException, DotSecurityException { + + // Use existing 2-param method to get asset metadata + final AssetMetadata metadata = getAssetMetadata(asset, user); + + // Use existing 2-param method to get role permissions + final List rolePermissions = buildRolePermissions(asset, user); + + // Build typed AssetPermissionsView from metadata and permissions + final AssetPermissionsView assetView = AssetPermissionsView.builder() + .assetId(metadata.assetId()) + .assetType(metadata.assetType()) + .inheritanceMode(metadata.inheritanceMode()) + .isParentPermissionable(metadata.isParentPermissionable()) + .canEditPermissions(metadata.canEditPermissions()) + .canEdit(metadata.canEdit()) + .parentAssetId(metadata.parentAssetId()) + .permissions(rolePermissions) + .build(); + + // Return typed response view + return UpdateAssetPermissionsView.builder() + .message("Permissions saved successfully") + .permissionCount(permissionCount) + .inheritanceBroken(inheritanceBroken) + .asset(assetView) + .build(); + } +} diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/permission/PermissionConversionUtils.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/permission/PermissionConversionUtils.java new file mode 100644 index 000000000000..d9d4cad3fe05 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/permission/PermissionConversionUtils.java @@ -0,0 +1,209 @@ +package com.dotcms.rest.api.v1.system.permission; + +import com.dotmarketing.beans.Host; +import com.dotmarketing.business.PermissionAPI; +import com.dotmarketing.portlets.categories.model.Category; +import com.dotmarketing.portlets.containers.model.Container; +import com.dotmarketing.portlets.contentlet.model.Contentlet; +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.util.Logger; +import com.dotmarketing.util.UtilMethods; +import com.liferay.util.StringPool; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Shared utility class for permission conversion operations. + * Provides static methods for converting between permission representations + * (bits, names, types) used by REST API endpoints. + * + *

This utility centralizes permission conversion logic to avoid duplication + * across helper classes like {@link AssetPermissionHelper} and + * {@link com.dotcms.rest.api.v1.user.UserPermissionHelper}. + * + * @author dotCMS + * @since 24.01 + */ +public final class PermissionConversionUtils { + + private PermissionConversionUtils() { + // Utility class - prevent instantiation + } + + /** + * Maps internal permission type class names to modern API type constants. + * Keys are uppercase for case-insensitive lookup. + */ + public static final Map PERMISSION_TYPE_MAPPINGS = Map.ofEntries( + Map.entry(PermissionAPI.INDIVIDUAL_PERMISSION_TYPE.toUpperCase(), "INDIVIDUAL"), + Map.entry(IHTMLPage.class.getCanonicalName().toUpperCase(), "PAGE"), + Map.entry(Container.class.getCanonicalName().toUpperCase(), "CONTAINER"), + Map.entry(Folder.class.getCanonicalName().toUpperCase(), "FOLDER"), + Map.entry(Link.class.getCanonicalName().toUpperCase(), "LINK"), + Map.entry(Template.class.getCanonicalName().toUpperCase(), "TEMPLATE"), + Map.entry(TemplateLayout.class.getCanonicalName().toUpperCase(), "TEMPLATE_LAYOUT"), + Map.entry(Structure.class.getCanonicalName().toUpperCase(), "CONTENT_TYPE"), + Map.entry(Contentlet.class.getCanonicalName().toUpperCase(), "CONTENT"), + Map.entry(Category.class.getCanonicalName().toUpperCase(), "CATEGORY"), + Map.entry(Rule.class.getCanonicalName().toUpperCase(), "RULE"), + Map.entry(Host.class.getCanonicalName().toUpperCase(), "HOST") + ); + + /** + * Maps API scope names to internal permission type class names. + * Reverse of PERMISSION_TYPE_MAPPINGS for use in update operations. + */ + public static final Map SCOPE_TO_TYPE_MAPPINGS = Map.ofEntries( + Map.entry("INDIVIDUAL", PermissionAPI.INDIVIDUAL_PERMISSION_TYPE), + Map.entry("FOLDER", Folder.class.getCanonicalName()), + Map.entry("HOST", Host.class.getCanonicalName()), + Map.entry("CONTENT", Contentlet.class.getCanonicalName()), + Map.entry("PAGE", IHTMLPage.class.getCanonicalName()), + Map.entry("CONTAINER", Container.class.getCanonicalName()), + Map.entry("TEMPLATE", Template.class.getCanonicalName()), + Map.entry("TEMPLATE_LAYOUT", TemplateLayout.class.getCanonicalName()), + Map.entry("LINK", Link.class.getCanonicalName()), + Map.entry("CONTENT_TYPE", Structure.class.getCanonicalName()), + Map.entry("CATEGORY", Category.class.getCanonicalName()), + Map.entry("RULE", Rule.class.getCanonicalName()) + ); + + /** + * Maps permission level names to their bit values. + * Used for both validation and conversion. + */ + public static final Map PERMISSION_NAME_TO_BITS = Map.of( + "READ", PermissionAPI.PERMISSION_READ, + "WRITE", PermissionAPI.PERMISSION_WRITE, + "PUBLISH", PermissionAPI.PERMISSION_PUBLISH, + "EDIT_PERMISSIONS", PermissionAPI.PERMISSION_EDIT_PERMISSIONS, + "CAN_ADD_CHILDREN", PermissionAPI.PERMISSION_CAN_ADD_CHILDREN + ); + + /** + * Valid permission level names for validation. + * Derived from PERMISSION_NAME_TO_BITS keys for single source of truth. + */ + public static final Set VALID_PERMISSION_LEVELS = PERMISSION_NAME_TO_BITS.keySet(); + + /** + * Gets the modern API type name for a permission type. + * + * @param permissionType Internal permission type (class name or scope) + * @return Modern API type constant (e.g., "FOLDER", "HOST", "CONTENT") + */ + public static String getModernPermissionType(final String permissionType) { + if (!UtilMethods.isSet(permissionType)) { + return StringPool.BLANK; + } + + final String mappedType = PERMISSION_TYPE_MAPPINGS.get(permissionType.toUpperCase()); + if (mappedType != null) { + return mappedType; + } + + Logger.debug(PermissionConversionUtils.class, + () -> String.format("Unknown permission type: %s", permissionType)); + return permissionType.toUpperCase(); + } + + /** + * Converts permission bits to permission level names. + * Avoids duplicate aliases (e.g., USE=READ, EDIT=WRITE). + * + * @param permissionBits Bit-packed permission value + * @return List of permission level strings (e.g., ["READ", "WRITE"]) + */ + public static 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 a bitwise permission value. + * + * @param permissionNames List of permission level names (READ, WRITE, etc.) + * @return Combined bit value + */ + public static int convertPermissionNamesToBits(final List permissionNames) { + if (permissionNames == null || permissionNames.isEmpty()) { + return 0; + } + + int bits = 0; + for (final String name : permissionNames) { + final Integer bitValue = PERMISSION_NAME_TO_BITS.get(name.toUpperCase()); + if (bitValue != null) { + bits |= bitValue; + } else { + Logger.warn(PermissionConversionUtils.class, + String.format("Unknown permission name: %s", name)); + } + } + return bits; + } + + /** + * Converts an API scope name to internal permission type. + * + * @param scopeName API scope name (FOLDER, CONTENT, etc.) + * @return Internal permission type (class canonical name) + * @throws IllegalArgumentException If scope is unknown + */ + public static String convertScopeToPermissionType(final String scopeName) { + final String type = SCOPE_TO_TYPE_MAPPINGS.get(scopeName.toUpperCase()); + if (type == null) { + throw new IllegalArgumentException(String.format( + "Invalid permission scope: %s", scopeName)); + } + return type; + } + + /** + * Validates that a permission level name is valid. + * + * @param permissionName Permission level name to validate + * @return true if valid, false otherwise + */ + public static boolean isValidPermissionLevel(final String permissionName) { + return permissionName != null && + VALID_PERMISSION_LEVELS.contains(permissionName.toUpperCase()); + } + + /** + * Validates that a scope name is valid. + * + * @param scopeName Scope name to validate + * @return true if valid, false otherwise + */ + public static boolean isValidScope(final String scopeName) { + return scopeName != null && + SCOPE_TO_TYPE_MAPPINGS.containsKey(scopeName.toUpperCase()); + } +} 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 0addb99fe862..8c482a2d3920 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 @@ -8,15 +8,12 @@ import com.dotcms.rest.WebResource; import com.dotcms.rest.annotation.NoCache; import com.dotcms.rest.annotation.SwaggerCompliant; -import com.dotcms.rest.exception.BadRequestException; import com.dotcms.rest.api.v1.user.UserResourceHelper; -import com.dotcms.rest.InitDataObject; import com.dotcms.rest.exception.BadRequestException; import com.dotmarketing.beans.Permission; import com.dotmarketing.business.APILocator; import com.dotmarketing.business.PermissionAPI; import com.dotmarketing.business.Permissionable; -import com.dotmarketing.business.Permissionable; import com.dotmarketing.business.Role; import com.dotmarketing.business.RoleAPI; import com.dotmarketing.business.UserAPI; @@ -27,6 +24,7 @@ import com.dotmarketing.util.UtilMethods; import com.dotcms.util.PaginationUtil; import com.dotcms.util.PaginationUtilParams; +import com.dotcms.util.pagination.AssetPermissionsPaginator; import com.dotcms.util.pagination.UserPermissionsPaginator; import com.google.common.annotations.VisibleForTesting; import com.liferay.portal.model.User; @@ -54,6 +52,8 @@ import javax.ws.rs.core.Context; import javax.ws.rs.core.MediaType; import javax.ws.rs.core.Response; + +import io.swagger.v3.oas.annotations.parameters.RequestBody; import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; @@ -77,6 +77,8 @@ public class PermissionResource { private final RoleAPI roleAPI; private final PermissionSaveHelper permissionSaveHelper; private final UserPermissionsPaginator userPermissionsPaginator; + private final AssetPermissionHelper assetPermissionHelper; + private final AssetPermissionsPaginator assetPermissionsPaginator; public PermissionResource() { this(new WebResource(), @@ -84,7 +86,9 @@ public PermissionResource() { APILocator.getUserAPI(), APILocator.getRoleAPI(), new PermissionSaveHelper(), - new UserPermissionsPaginator()); + new UserPermissionsPaginator(), + new AssetPermissionHelper(), + new AssetPermissionsPaginator()); } @VisibleForTesting @@ -94,23 +98,29 @@ public PermissionResource(final PermissionSaveHelper permissionSaveHelper) { APILocator.getUserAPI(), APILocator.getRoleAPI(), permissionSaveHelper, - new UserPermissionsPaginator(permissionSaveHelper)); + new UserPermissionsPaginator(permissionSaveHelper), + new AssetPermissionHelper(), + new AssetPermissionsPaginator()); } @VisibleForTesting - public PermissionResource(final WebResource webResource, - final PermissionHelper permissionHelper, - final UserAPI userAPI, - final RoleAPI roleAPI, + public PermissionResource(final WebResource webResource, + final PermissionHelper permissionHelper, + final UserAPI userAPI, + final RoleAPI roleAPI, final PermissionSaveHelper permissionSaveHelper, - final UserPermissionsPaginator userPermissionsPaginator) { - - this.webResource = webResource; - this.permissionHelper = permissionHelper; - this.userAPI = userAPI; - this.roleAPI = roleAPI; - this.permissionSaveHelper = permissionSaveHelper; - this.userPermissionsPaginator = userPermissionsPaginator; + final UserPermissionsPaginator userPermissionsPaginator, + final AssetPermissionHelper assetPermissionHelper, + final AssetPermissionsPaginator assetPermissionsPaginator) { + + this.webResource = webResource; + this.permissionHelper = permissionHelper; + this.userAPI = userAPI; + this.roleAPI = roleAPI; + this.permissionSaveHelper = permissionSaveHelper; + this.userPermissionsPaginator = userPermissionsPaginator; + this.assetPermissionHelper = assetPermissionHelper; + this.assetPermissionsPaginator = assetPermissionsPaginator; } /** @@ -591,6 +601,136 @@ public static PermissionView from(Permission permission) { return view; } + /** + * Retrieves permissions for a specific asset, including both individual and inherited permissions. + * Results are paginated by role. Supports all permissionable asset types (Host, Folder, Contentlet, + * Template, Container, Category, ContentType, Link, Rule, etc.). + * + * @param request HTTP servlet request + * @param response HTTP servlet response + * @param assetId Asset identifier (inode or identifier) + * @param page Page number for pagination (1-indexed, default: 1) + * @param perPage Number of roles to return per page (default: 40, max: 100) + * @return ResponseEntityAssetPermissionsView containing asset metadata, paginated permissions, and pagination metadata + * @throws DotDataException If there's an error accessing permission data + * @throws DotSecurityException If security validation fails + */ + @Operation( + summary = "Get asset permissions", + description = "Retrieves permissions for a specific asset by its identifier (inode or identifier). " + + "Returns asset metadata, a paginated list of roles with their permission levels, " + + "and pagination information. Supports all permissionable asset types including hosts, " + + "folders, contentlets, templates, containers, categories, links, and rules." + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", + description = "Permissions retrieved successfully", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ResponseEntityPaginatedDataView.class))), + @ApiResponse(responseCode = "400", + description = "Bad request - invalid query parameters (page < 1 or per_page not in 1-100 range)", + content = @Content(mediaType = "application/json")), + @ApiResponse(responseCode = "401", + description = "Unauthorized - authentication required", + content = @Content(mediaType = "application/json")), + @ApiResponse(responseCode = "403", + description = "Forbidden - user lacks permission to view asset", + content = @Content(mediaType = "application/json")), + @ApiResponse(responseCode = "404", + description = "Asset not found", + content = @Content(mediaType = "application/json")) + }) + @GET + @Path("/{assetId}") + @JSONP + @NoCache + @Produces({MediaType.APPLICATION_JSON}) + public ResponseEntityPaginatedDataView getAssetPermissions( + final @Context HttpServletRequest request, + final @Context HttpServletResponse response, + @Parameter(description = "Asset identifier (inode or identifier)", required = true) + final @PathParam("assetId") String assetId, + @Parameter(description = "Page number for pagination (1-indexed)", required = false, example = "1") + final @QueryParam("page") @DefaultValue("1") Integer page, + @Parameter(description = "Number of roles to return per page (max: 100)", required = false, example = "40") + final @QueryParam("per_page") @DefaultValue("40") Integer perPage) + throws DotDataException, DotSecurityException { + + Logger.debug(this, () -> String.format( + "getAssetPermissions called - assetId: %s, page: %d, per_page: %d", + assetId, page, perPage)); + + // Initialize request context with authentication + final User user = new WebResource.InitBuilder(webResource) + .requiredBackendUser(true) + .requiredFrontendUser(false) + .requestAndResponse(request, response) + .rejectWhenNoUser(true) + .init() + .getUser(); + + // Validate input parameters + if (!UtilMethods.isSet(assetId)) { + Logger.warn(this, "Asset ID is required but was not provided"); + throw new IllegalArgumentException("Asset ID is required"); + } + + // Resolve the asset to verify it exists + final Permissionable asset = assetPermissionHelper.resolveAsset(assetId); + if (asset == null) { + Logger.warn(this, String.format("Asset not found: %s", assetId)); + throw new NotFoundInDbException(String.format("Asset not found: %s", assetId)); + } + + // Check if user has READ permission on the asset + final PermissionAPI permissionAPI = APILocator.getPermissionAPI(); + final boolean hasReadPermission = permissionAPI.doesUserHavePermission( + asset, PermissionAPI.PERMISSION_READ, user, false); + + if (!hasReadPermission) { + Logger.warn(this, String.format("User %s does not have READ access to asset: %s", + user.getUserId(), assetId)); + throw new DotSecurityException("User does not have permission to view this asset's permissions"); + } + + // Get asset metadata for the response + final AssetPermissionHelper.AssetMetadata metadata = assetPermissionHelper.getAssetMetadata(asset, user); + + // Use PaginationUtil with paginator (same pattern as getUserPermissions) + final PaginationUtil paginationUtil = new PaginationUtil(assetPermissionsPaginator); + + final Map extraParams = Map.of( + AssetPermissionsPaginator.ASSET_PARAM, asset, + AssetPermissionsPaginator.REQUESTING_USER_PARAM, user + ); + + final PaginationUtilParams params = + new PaginationUtilParams.Builder() + .withRequest(request) + .withResponse(response) + .withUser(user) + .withPage(page) + .withPerPage(perPage) + .withExtraParams(extraParams) + .withFunction(paginatedRoles -> AssetPermissionsView.builder() + .assetId(metadata.assetId()) + .assetType(metadata.assetType()) + .inheritanceMode(metadata.inheritanceMode()) + .isParentPermissionable(metadata.isParentPermissionable()) + .canEditPermissions(metadata.canEditPermissions()) + .canEdit(metadata.canEdit()) + .parentAssetId(metadata.parentAssetId()) + .permissions(paginatedRoles) + .build()) + .build(); + + Logger.debug(this, () -> String.format( + "Retrieving permission roles for asset %s (page %d, perPage %d)", + assetId, page, perPage)); + + return paginationUtil.getPageView(params); + } + /** * Loads a user by ID or email. * First tries to load by user ID, then falls back to email lookup. @@ -615,4 +755,98 @@ private User loadUserByIdOrEmail(final String userIdOrEmail, final User systemUs } } } + + /** + * Updates permissions for a specific asset. This operation replaces all permissions for + * the asset. If the asset is currently inheriting permissions, inheritance will be + * automatically broken before applying the new permissions. + * + * @param request HTTP servlet request + * @param response HTTP servlet response + * @param assetId Asset identifier (inode or identifier) + * @param cascade If true, triggers async job to cascade permissions to descendant assets + * @param form Request body containing permissions to save + * @return ResponseEntityUpdatePermissionsView containing operation result and updated permissions + * @throws DotDataException If there's an error accessing permission data + * @throws DotSecurityException If security validation fails + */ + @Operation( + summary = "Update asset permissions", + description = "Replaces all permissions for a specific asset. If the asset is currently " + + "inheriting permissions, inheritance will be automatically broken. " + + "Only admin users can access this endpoint. Use cascade=true to trigger " + + "an async job that removes individual permissions from descendant assets." + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", + description = "Permissions updated successfully", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ResponseEntityUpdatePermissionsView.class))), + @ApiResponse(responseCode = "400", + description = "Bad request - invalid request body or role IDs", + content = @Content(mediaType = "application/json")), + @ApiResponse(responseCode = "401", + description = "Unauthorized - authentication required", + content = @Content(mediaType = "application/json")), + @ApiResponse(responseCode = "403", + description = "Forbidden - user is not admin or lacks EDIT_PERMISSIONS on asset", + content = @Content(mediaType = "application/json")), + @ApiResponse(responseCode = "404", + description = "Asset not found", + content = @Content(mediaType = "application/json")), + @ApiResponse(responseCode = "500", + description = "Failed to update permissions", + content = @Content(mediaType = "application/json")) + }) + @PUT + @Path("/{assetId}") + @JSONP + @NoCache + @Produces({MediaType.APPLICATION_JSON}) + @Consumes({MediaType.APPLICATION_JSON}) + public ResponseEntityUpdatePermissionsView updateAssetPermissions( + final @Context HttpServletRequest request, + final @Context HttpServletResponse response, + @Parameter(description = "Asset identifier (inode or identifier)", required = true) + final @PathParam("assetId") String assetId, + @Parameter(description = "If true, triggers async job to cascade permissions to descendants", required = false) + final @QueryParam("cascade") @DefaultValue("false") boolean cascade, + @RequestBody(description = "Permission update data", required = true, + content = @Content(schema = @Schema(implementation = UpdateAssetPermissionsForm.class))) + final UpdateAssetPermissionsForm form) + throws DotDataException, DotSecurityException { + + Logger.debug(this, () -> String.format( + "updateAssetPermissions called - assetId: %s, cascade: %s", + assetId, cascade)); + + // 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 permissions for asset: %s", + user.getUserId(), assetId)); + throw new DotSecurityException("Only admin users can update asset permissions"); + } + + // Validate form before processing (follows SaveUserPermissionsForm pattern) + form.checkValid(); + + // Delegate to helper for business logic + final UpdateAssetPermissionsView result = assetPermissionHelper.updateAssetPermissions( + assetId, form, cascade, user); + + Logger.info(this, () -> String.format( + "Successfully updated permissions for asset: %s", assetId)); + + return new ResponseEntityUpdatePermissionsView(result); + } } diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/permission/ResponseEntityAssetPermissionsView.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/permission/ResponseEntityAssetPermissionsView.java new file mode 100644 index 000000000000..a85af03afacb --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/permission/ResponseEntityAssetPermissionsView.java @@ -0,0 +1,26 @@ +package com.dotcms.rest.api.v1.system.permission; + +import com.dotcms.rest.Pagination; +import com.dotcms.rest.ResponseEntityView; + +/** + * Response wrapper for asset permissions endpoint. + * Used by PermissionResource.getAssetPermissions() to return paginated + * permission data organized by roles for a specific asset. + * + * @author hassandotcms + * @since 24.01 + */ +public class ResponseEntityAssetPermissionsView extends ResponseEntityView { + + /** + * Constructor for asset permissions response with pagination. + * Places pagination at root level alongside entity. + * + * @param entity AssetPermissionsView containing asset metadata and paginated role permissions + * @param pagination Pagination metadata (currentPage, perPage, totalEntries) + */ + public ResponseEntityAssetPermissionsView(final AssetPermissionsView entity, final Pagination pagination) { + super(entity, pagination); + } +} diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/permission/ResponseEntityUpdatePermissionsView.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/permission/ResponseEntityUpdatePermissionsView.java new file mode 100644 index 000000000000..4bfe10b91caa --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/permission/ResponseEntityUpdatePermissionsView.java @@ -0,0 +1,43 @@ +package com.dotcms.rest.api.v1.system.permission; + +import com.dotcms.rest.ResponseEntityView; + +/** + * Response entity view for the PUT /api/v1/permissions/{assetId} endpoint. + * + *

Response structure: + *

{@code
+ * {
+ *   "entity": {
+ *     "message": "Permissions saved successfully",
+ *     "permissionCount": 3,
+ *     "inheritanceBroken": true,
+ *     "asset": {
+ *       "assetId": "asset-123",
+ *       "assetType": "contentlet",
+ *       "inheritanceMode": "INDIVIDUAL",
+ *       "isParentPermissionable": false,
+ *       "canEditPermissions": true,
+ *       "canEdit": true,
+ *       "parentAssetId": "parent-folder-123",
+ *       "permissions": [...]
+ *     }
+ *   }
+ * }
+ * }
+ * + * @author dotCMS + * @since 24.01 + */ +public class ResponseEntityUpdatePermissionsView extends ResponseEntityView { + + /** + * Creates a new ResponseEntityUpdatePermissionsView. + * + * @param entity UpdateAssetPermissionsView containing message, permissionCount, + * inheritanceBroken, and asset data + */ + public ResponseEntityUpdatePermissionsView(final UpdateAssetPermissionsView entity) { + super(entity); + } +} diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/permission/RolePermissionForm.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/permission/RolePermissionForm.java new file mode 100644 index 000000000000..71bc8292ab55 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/permission/RolePermissionForm.java @@ -0,0 +1,100 @@ +package com.dotcms.rest.api.v1.system.permission; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.List; +import java.util.Map; + +/** + * Form representing permissions for a single role in an asset permission update request. + * Used as part of {@link UpdateAssetPermissionsForm}. + * + *

Example JSON: + *

{@code
+ * {
+ *   "roleId": "role-123",
+ *   "individual": ["READ", "WRITE", "PUBLISH"],
+ *   "inheritable": {
+ *     "FOLDER": ["READ", "CAN_ADD_CHILDREN"],
+ *     "CONTENT": ["READ", "WRITE", "PUBLISH"]
+ *   }
+ * }
+ * }
+ * + * @author dotCMS + * @since 24.01 + */ +@Schema(description = "Permission assignment for a single role on an asset") +public class RolePermissionForm { + + @JsonProperty("roleId") + @Schema( + description = "Role identifier. Can be role ID or role key.", + example = "abc-123-def-456", + requiredMode = Schema.RequiredMode.REQUIRED + ) + private final String roleId; + + @JsonProperty("individual") + @Schema( + description = "Individual permission levels for this asset. Valid values: READ, WRITE, PUBLISH, EDIT_PERMISSIONS, CAN_ADD_CHILDREN", + example = "[\"READ\", \"WRITE\", \"PUBLISH\"]" + ) + private final List individual; + + @JsonProperty("inheritable") + @Schema( + description = "Inheritable permissions by scope for child assets. Keys are permission scopes (FOLDER, CONTENT, PAGE, etc.), values are lists of permission levels.", + example = "{\"FOLDER\": [\"READ\", \"CAN_ADD_CHILDREN\"], \"CONTENT\": [\"READ\", \"WRITE\"]}" + ) + private final Map> inheritable; + + /** + * Creates a new RolePermissionForm. + * + * @param roleId Role identifier (required) + * @param individual Permission levels for this asset (e.g., ["READ", "WRITE"]) + * @param inheritable Permission scopes to permission levels for child assets + */ + @JsonCreator + public RolePermissionForm( + @JsonProperty("roleId") final String roleId, + @JsonProperty("individual") final List individual, + @JsonProperty("inheritable") final Map> inheritable) { + this.roleId = roleId; + this.individual = individual; + this.inheritable = inheritable; + } + + /** + * Gets the role identifier. + * + * @return Role ID string + */ + public String getRoleId() { + return roleId; + } + + /** + * Gets the individual (direct) permission levels for this asset. + * Valid values: READ, WRITE, PUBLISH, EDIT_PERMISSIONS, CAN_ADD_CHILDREN + * + * @return List of permission level strings, or null if not specified + */ + public List getIndividual() { + return individual; + } + + /** + * Gets the inheritable permissions map for child assets. + * Keys are permission scopes (FOLDER, CONTENT, PAGE, etc.) + * Values are lists of permission level strings. + * + * @return Map of scope to permission levels, or null if not specified + */ + public Map> getInheritable() { + return inheritable; + } +} diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/permission/UpdateAssetPermissionsForm.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/permission/UpdateAssetPermissionsForm.java new file mode 100644 index 000000000000..babde9217486 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/permission/UpdateAssetPermissionsForm.java @@ -0,0 +1,164 @@ +package com.dotcms.rest.api.v1.system.permission; + +import com.dotcms.rest.api.Validated; +import com.dotcms.rest.exception.BadRequestException; +import com.dotmarketing.util.UtilMethods; +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; +import java.util.Set; + +/** + * Form for updating asset permissions via PUT /api/v1/permissions/{assetId}. + * + *

This form represents the request body for the asset permissions update endpoint. + * Note: The {@code cascade} parameter is passed as a query parameter, not in this form. + * + *

Example JSON: + *

{@code
+ * {
+ *   "permissions": [
+ *     {
+ *       "roleId": "role-123",
+ *       "individual": ["READ", "WRITE", "PUBLISH"],
+ *       "inheritable": {
+ *         "FOLDER": ["READ", "CAN_ADD_CHILDREN"],
+ *         "CONTENT": ["READ", "WRITE", "PUBLISH"]
+ *       }
+ *     },
+ *     {
+ *       "roleId": "role-456",
+ *       "individual": ["READ"]
+ *     }
+ *   ]
+ * }
+ * }
+ * + * @author dotCMS + * @since 24.01 + */ +public class UpdateAssetPermissionsForm extends Validated { + + @JsonProperty("permissions") + @Schema( + description = "List of role permission entries. Each entry defines permissions for a specific role on the asset.", + requiredMode = Schema.RequiredMode.REQUIRED + ) + @NotNull(message = "permissions is required") + private final List permissions; + + /** + * Creates a new UpdateAssetPermissionsForm. + * + * @param permissions List of role permission entries (required) + */ + @JsonCreator + public UpdateAssetPermissionsForm( + @JsonProperty("permissions") final List permissions) { + this.permissions = permissions; + } + + /** + * Gets the list of role permission entries. + * Each entry defines permissions for a specific role on the asset. + * + * @return List of RolePermissionForm entries + */ + public List getPermissions() { + return permissions; + } + + @Override + public void checkValid() { + super.checkValid(); // JSR-303 validation + + if (permissions == null || permissions.isEmpty()) { + throw new BadRequestException("permissions cannot be empty"); + } + + final Set validScopes = PermissionConversionUtils.SCOPE_TO_TYPE_MAPPINGS.keySet(); + final Set validLevels = PermissionConversionUtils.VALID_PERMISSION_LEVELS; + + for (int i = 0; i < permissions.size(); i++) { + final RolePermissionForm roleForm = permissions.get(i); + validateRolePermissionForm(roleForm, i, validScopes, validLevels); + } + } + + /** + * Validates a single RolePermissionForm entry. + */ + private void validateRolePermissionForm(final RolePermissionForm roleForm, + final int index, + final Set validScopes, + final Set validLevels) { + if (roleForm == null) { + throw new BadRequestException( + String.format("permissions[%d] cannot be null", index)); + } + + if (!UtilMethods.isSet(roleForm.getRoleId())) { + throw new BadRequestException( + String.format("permissions[%d].roleId is required", index)); + } + + // Must have at least individual or inheritable permissions + final boolean hasIndividual = roleForm.getIndividual() != null && !roleForm.getIndividual().isEmpty(); + final boolean hasInheritable = roleForm.getInheritable() != null && !roleForm.getInheritable().isEmpty(); + + if (!hasIndividual && !hasInheritable) { + throw new BadRequestException( + String.format("permissions[%d] must have at least 'individual' or 'inheritable' permissions", index)); + } + + // Validate individual permission levels + if (hasIndividual) { + for (final String level : roleForm.getIndividual()) { + if (level == null) { + throw new BadRequestException( + String.format("permissions[%d].individual contains null value", index)); + } + if (!validLevels.contains(level.toUpperCase())) { + throw new BadRequestException( + String.format("permissions[%d].individual contains invalid level '%s'. Valid levels: %s", + index, level, validLevels)); + } + } + } + + // Validate inheritable permissions (scope -> levels) + if (hasInheritable) { + for (final Map.Entry> entry : roleForm.getInheritable().entrySet()) { + final String scope = entry.getKey(); + final List levels = entry.getValue(); + + if (scope == null || !validScopes.contains(scope.toUpperCase())) { + throw new BadRequestException( + String.format("permissions[%d].inheritable contains invalid scope '%s'. Valid scopes: %s", + index, scope, validScopes)); + } + + if (levels == null || levels.isEmpty()) { + throw new BadRequestException( + String.format("permissions[%d].inheritable['%s'] cannot be null or empty", index, scope)); + } + + for (final String level : levels) { + if (level == null) { + throw new BadRequestException( + String.format("permissions[%d].inheritable['%s'] contains null value", index, scope)); + } + if (!validLevels.contains(level.toUpperCase())) { + throw new BadRequestException( + String.format("permissions[%d].inheritable['%s'] contains invalid level '%s'. Valid levels: %s", + index, scope, level, validLevels)); + } + } + } + } + } +} diff --git a/dotCMS/src/main/java/com/dotcms/util/pagination/AssetPermissionsPaginator.java b/dotCMS/src/main/java/com/dotcms/util/pagination/AssetPermissionsPaginator.java new file mode 100644 index 000000000000..12b6a4cfe90f --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/util/pagination/AssetPermissionsPaginator.java @@ -0,0 +1,113 @@ +package com.dotcms.util.pagination; + +import com.dotcms.rest.api.v1.system.permission.AssetPermissionHelper; +import com.dotcms.rest.api.v1.system.permission.RolePermissionView; +import com.dotmarketing.business.Permissionable; +import com.dotmarketing.exception.DotDataException; +import com.dotmarketing.exception.DotRuntimeException; +import com.dotmarketing.util.Logger; +import com.dotmarketing.util.PaginatedArrayList; +import com.google.common.annotations.VisibleForTesting; +import com.liferay.portal.model.User; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * Paginator for asset permission roles with in-memory pagination. + * Similar to {@link UserPermissionsPaginator}, this pagination happens in-memory + * since permission data is not retrieved directly from a database query + * with offset/limit support. + * + * @author hassandotcms + * @since 24.01 + */ +public class AssetPermissionsPaginator implements Paginator { + + /** + * Parameter name for the permissionable asset. + */ + public static final String ASSET_PARAM = "asset"; + + /** + * Parameter name for the requesting user (for permission checks). + */ + public static final String REQUESTING_USER_PARAM = "requestingUser"; + + private final AssetPermissionHelper assetPermissionHelper; + + public AssetPermissionsPaginator() { + this(new AssetPermissionHelper()); + } + + @VisibleForTesting + public AssetPermissionsPaginator(final AssetPermissionHelper assetPermissionHelper) { + this.assetPermissionHelper = assetPermissionHelper; + } + + /** + * Returns a paginated list of role permissions for an asset. + *

+ * This implementation fetches all role permissions first and then applies pagination + * in-memory using stream skip/limit, similar to {@link UserPermissionsPaginator}. + * + * @param user The requesting user (for permission checks) + * @param limit Number of items to return + * @param offset Starting offset + * @param params Extra parameters containing: + * - {@link #ASSET_PARAM}: The {@link Permissionable} asset + * - {@link #REQUESTING_USER_PARAM}: The requesting {@link User} + * @return Paginated list of role permissions + * @throws PaginationException if pagination fails + */ + @Override + public PaginatedArrayList getItems( + final User user, + final int limit, + final int offset, + final Map params) throws PaginationException { + + final Permissionable asset = (Permissionable) params.get(ASSET_PARAM); + final User requestingUser = (User) params.get(REQUESTING_USER_PARAM); + + try { + if (asset == null) { + throw new PaginationException( + new IllegalArgumentException("Asset parameter is required")); + } + + Logger.debug(this, () -> String.format( + "AssetPermissionsPaginator: assetId=%s, offset=%d, limit=%d", + asset.getPermissionId(), offset, limit)); + + // Get all role permissions (in-memory pagination like UserPermissionsPaginator) + final List allRolePermissions = + assetPermissionHelper.buildRolePermissions(asset, requestingUser); + + final long totalCount = allRolePermissions.size(); + + // In-memory pagination using stream skip/limit + final List pagedRoles = allRolePermissions.stream() + .skip(offset) + .limit(limit) + .collect(Collectors.toList()); + + final PaginatedArrayList result = new PaginatedArrayList<>(); + result.addAll(pagedRoles); + result.setTotalResults(totalCount); + + Logger.debug(this, () -> String.format( + "AssetPermissionsPaginator: returning %d of %d total roles", + pagedRoles.size(), totalCount)); + + return result; + + } catch (DotDataException e) { + Logger.error(this, String.format( + "Error getting asset permission roles for pagination, assetId=%s", + asset != null ? asset.getPermissionId() : "null"), e); + throw new DotRuntimeException("Error retrieving asset permission roles", e); + } + } +} diff --git a/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml b/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml index 6261047fa570..d8eadf4d2ee4 100644 --- a/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml +++ b/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml @@ -10812,6 +10812,121 @@ paths: summary: Update user permissions on asset tags: - Permissions + /v1/permissions/{assetId}: + get: + description: "Retrieves permissions for a specific asset by its identifier (inode\ + \ or identifier). Returns asset metadata, a paginated list of roles with their\ + \ permission levels, and pagination information. Supports all permissionable\ + \ asset types including hosts, folders, contentlets, templates, containers,\ + \ categories, links, and rules." + operationId: getAssetPermissions + parameters: + - description: Asset identifier (inode or identifier) + in: path + name: assetId + required: true + schema: + type: string + - description: Page number for pagination (1-indexed) + example: 1 + in: query + name: page + schema: + type: integer + format: int32 + default: 1 + - description: "Number of roles to return per page (max: 100)" + example: 40 + in: query + name: per_page + schema: + type: integer + format: int32 + default: 40 + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/ResponseEntityPaginatedDataView" + description: Permissions retrieved successfully + "400": + content: + application/json: {} + description: Bad request - invalid query parameters (page < 1 or per_page + not in 1-100 range) + "401": + content: + application/json: {} + description: Unauthorized - authentication required + "403": + content: + application/json: {} + description: Forbidden - user lacks permission to view asset + "404": + content: + application/json: {} + description: Asset not found + summary: Get asset permissions + tags: + - Permissions + put: + description: "Replaces all permissions for a specific asset. If the asset is\ + \ currently inheriting permissions, inheritance will be automatically broken.\ + \ Only admin users can access this endpoint. Use cascade=true to trigger an\ + \ async job that removes individual permissions from descendant assets." + operationId: updateAssetPermissions + parameters: + - description: Asset identifier (inode or identifier) + in: path + name: assetId + required: true + schema: + type: string + - description: "If true, triggers async job to cascade permissions to descendants" + in: query + name: cascade + schema: + type: boolean + default: false + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateAssetPermissionsForm" + description: Permission update data + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/ResponseEntityUpdatePermissionsView" + description: Permissions updated successfully + "400": + content: + application/json: {} + description: Bad request - invalid request body or role IDs + "401": + content: + application/json: {} + description: Unauthorized - authentication required + "403": + content: + application/json: {} + description: Forbidden - user is not admin or lacks EDIT_PERMISSIONS on + asset + "404": + content: + application/json: {} + description: Asset not found + "500": + content: + application/json: {} + description: Failed to update permissions + summary: Update asset permissions + tags: + - Permissions /v1/personalization/pagepersonas: post: description: Copies the current content associated to page containers with default @@ -19207,6 +19322,73 @@ components: example: //demo.dotcms.com/documents/annual-report.pdf required: - assetPath + AssetPermissionsView: + type: object + description: The updated asset with its new permission assignments + properties: + assetId: + type: string + description: Asset identifier + example: 48190c8c-42c4-46af-8d1a-0cd5db894797 + assetType: + type: string + description: Asset type + enum: + - HOST + - FOLDER + - CONTENT + - TEMPLATE + - CONTAINER + - PAGE + - LINK + - CATEGORY + - RULE + - CONTENT_TYPE + example: FOLDER + canEdit: + type: boolean + description: Whether the requesting user can edit this asset + example: true + canEditPermissions: + type: boolean + description: Whether the requesting user can edit permissions on this asset + example: true + inheritanceMode: + type: string + description: Permission inheritance mode + enum: + - INHERITED + - INDIVIDUAL + example: INDIVIDUAL + isParentPermissionable: + type: boolean + description: "Whether this asset can have child permissionables (e.g., hosts\ + \ and folders)" + example: true + parentAssetId: + type: string + description: Parent asset identifier (null if no parent or at root level) + example: abc-123-def-456 + permissions: + type: array + description: Paginated list of role permissions assigned to this asset + items: + $ref: "#/components/schemas/RolePermissionView" + properties: + empty: + type: boolean + first: + $ref: "#/components/schemas/RolePermissionView" + last: + $ref: "#/components/schemas/RolePermissionView" + required: + - assetId + - assetType + - canEdit + - canEditPermissions + - inheritanceMode + - isParentPermissionable + - permissions AssetsRequestForm: type: object properties: @@ -22339,19 +22521,31 @@ components: $ref: "#/components/schemas/JobView" last: $ref: "#/components/schemas/JobView" + ImmutableListRolePermissionView: + type: array + description: Paginated list of role permissions assigned to this asset + items: + $ref: "#/components/schemas/RolePermissionView" + properties: + empty: + type: boolean + first: + $ref: "#/components/schemas/RolePermissionView" + last: + $ref: "#/components/schemas/RolePermissionView" ImmutableListString: type: array - description: "List of file patterns that are allowed in this folder (e.g., *.jpg,\ - \ *.pdf)" + description: Individual permission levels assigned directly to this role on + the asset example: - - '*.jpg' - - '*.png' - - '*.pdf' + - READ + - WRITE + - PUBLISH items: type: string - description: "List of file patterns that are allowed in this folder (e.g.,\ - \ *.jpg, *.pdf)" - example: "[\"*.jpg\",\"*.png\",\"*.pdf\"]" + description: Individual permission levels assigned directly to this role on + the asset + example: "[\"READ\",\"WRITE\",\"PUBLISH\"]" properties: empty: type: boolean @@ -22386,6 +22580,37 @@ components: properties: empty: type: boolean + ImmutableMapStringListString: + type: object + additionalProperties: + type: array + description: "Inheritable permissions by scope (only for parent permissionables).\ + \ Keys are permission scopes (HOST, FOLDER, CONTENT, etc.), values are permission\ + \ level names" + example: + FOLDER: + - READ + - WRITE + CONTENT: + - READ + items: + type: string + description: "Inheritable permissions by scope (only for parent permissionables).\ + \ Keys are permission scopes (HOST, FOLDER, CONTENT, etc.), values are\ + \ permission level names" + example: "{\"FOLDER\":[\"READ\",\"WRITE\"],\"CONTENT\":[\"READ\"]}" + description: "Inheritable permissions by scope (only for parent permissionables).\ + \ Keys are permission scopes (HOST, FOLDER, CONTENT, etc.), values are permission\ + \ level names" + example: + FOLDER: + - READ + - WRITE + CONTENT: + - READ + properties: + empty: + type: boolean ImmutableMapStringObject: type: object additionalProperties: @@ -25256,6 +25481,29 @@ components: type: array items: type: string + ResponseEntityUpdatePermissionsView: + type: object + properties: + entity: + $ref: "#/components/schemas/UpdateAssetPermissionsView" + 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: @@ -26248,6 +26496,131 @@ components: uniqueItems: true roleId: type: string + RolePermissionForm: + type: object + description: Permission assignment for a single role on an asset + properties: + individual: + type: array + description: "Individual permission levels for this asset. Valid values:\ + \ READ, WRITE, PUBLISH, EDIT_PERMISSIONS, CAN_ADD_CHILDREN" + example: + - READ + - WRITE + - PUBLISH + items: + type: string + description: "Individual permission levels for this asset. Valid values:\ + \ READ, WRITE, PUBLISH, EDIT_PERMISSIONS, CAN_ADD_CHILDREN" + example: "[\"READ\",\"WRITE\",\"PUBLISH\"]" + inheritable: + type: object + additionalProperties: + type: array + description: "Inheritable permissions by scope for child assets. Keys\ + \ are permission scopes (FOLDER, CONTENT, PAGE, etc.), values are lists\ + \ of permission levels." + example: + CONTENT: + - READ + - WRITE + FOLDER: + - READ + - CAN_ADD_CHILDREN + items: + type: string + description: "Inheritable permissions by scope for child assets. Keys\ + \ are permission scopes (FOLDER, CONTENT, PAGE, etc.), values are\ + \ lists of permission levels." + example: "{\"FOLDER\":[\"READ\",\"CAN_ADD_CHILDREN\"],\"CONTENT\":[\"\ + READ\",\"WRITE\"]}" + description: "Inheritable permissions by scope for child assets. Keys are\ + \ permission scopes (FOLDER, CONTENT, PAGE, etc.), values are lists of\ + \ permission levels." + example: + CONTENT: + - READ + - WRITE + FOLDER: + - READ + - CAN_ADD_CHILDREN + roleId: + type: string + description: Role identifier. Can be role ID or role key. + example: abc-123-def-456 + required: + - roleId + RolePermissionView: + type: object + properties: + individual: + type: array + description: Individual permission levels assigned directly to this role + on the asset + example: + - READ + - WRITE + - PUBLISH + items: + type: string + description: Individual permission levels assigned directly to this role + on the asset + example: "[\"READ\",\"WRITE\",\"PUBLISH\"]" + properties: + empty: + type: boolean + first: + type: string + last: + type: string + inheritable: + type: object + additionalProperties: + type: array + description: "Inheritable permissions by scope (only for parent permissionables).\ + \ Keys are permission scopes (HOST, FOLDER, CONTENT, etc.), values are\ + \ permission level names" + example: + CONTENT: + - READ + FOLDER: + - READ + - WRITE + items: + type: string + description: "Inheritable permissions by scope (only for parent permissionables).\ + \ Keys are permission scopes (HOST, FOLDER, CONTENT, etc.), values\ + \ are permission level names" + example: "{\"FOLDER\":[\"READ\",\"WRITE\"],\"CONTENT\":[\"READ\"]}" + description: "Inheritable permissions by scope (only for parent permissionables).\ + \ Keys are permission scopes (HOST, FOLDER, CONTENT, etc.), values are\ + \ permission level names" + example: + CONTENT: + - READ + FOLDER: + - READ + - WRITE + properties: + empty: + type: boolean + inherited: + type: boolean + description: Whether permissions are inherited from a parent asset + example: false + roleId: + type: string + description: Role identifier + example: abc-123-def-456 + roleName: + type: string + description: Role display name + example: CMS Administrator + required: + - individual + - inherited + - roleId + - roleName RoleResponseEntityView: type: object properties: @@ -27507,6 +27880,41 @@ components: count: type: integer format: int64 + UpdateAssetPermissionsForm: + type: object + properties: + permissions: + type: array + description: List of role permission entries. Each entry defines permissions + for a specific role on the asset. + items: + $ref: "#/components/schemas/RolePermissionForm" + required: + - permissions + UpdateAssetPermissionsView: + type: object + properties: + asset: + $ref: "#/components/schemas/AssetPermissionsView" + inheritanceBroken: + type: boolean + description: Whether permission inheritance was broken during this operation. + True if the asset was previously inheriting permissions from its parent. + example: true + message: + type: string + description: Success message describing the operation result + example: Permissions saved successfully + permissionCount: + type: integer + format: int32 + description: Number of permission entries saved during this operation + example: 5 + required: + - asset + - inheritanceBroken + - message + - permissionCount UpdateCurrentUserForm: 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 f39f307a8276..a1f1b0ae9fd6 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 @@ -2,6 +2,7 @@ import com.dotcms.api.web.HttpServletRequestThreadLocal; import com.dotcms.datagen.FolderDataGen; +import com.dotcms.datagen.RoleDataGen; import com.dotcms.datagen.SiteDataGen; import com.dotcms.datagen.TestUserUtils; import com.dotcms.rest.WebResource; @@ -35,6 +36,7 @@ import javax.servlet.http.HttpServletResponse; import org.glassfish.jersey.internal.util.Base64; import static org.junit.Assert.*; +import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; import java.util.List; @@ -47,11 +49,12 @@ import org.junit.Test; /** - * Integration tests for PermissionResource user permissions endpoints. + * Integration tests for PermissionResource endpoints. * Tests: * - GET /api/v1/permissions/ (permission metadata) * - GET /api/v1/permissions/user/{userId} (get user permissions) * - PUT /api/v1/permissions/user/{userId}/asset/{assetId} (update user permissions) + * - PUT /api/v1/permissions/{assetId} (update asset permissions) */ public class PermissionResourceIntegrationTest { @@ -70,6 +73,9 @@ public class PermissionResourceIntegrationTest { static User permissionTestUser; static Host permissionTestHost; + // Test role for updateAssetPermissions tests + static Role testRole; + @BeforeClass public static void prepare() throws Exception { // Setting web app environment @@ -147,6 +153,9 @@ private static void setupUpdatePermissionTestData() throws Exception { true ); APILocator.getPermissionAPI().save(limitedPerm, updateTestHost, adminUser, false); + + // Create test role for updateAssetPermissions tests + testRole = new RoleDataGen().nextPersisted(); } @After @@ -732,4 +741,301 @@ public void test_getPermissionMetadata_success() throws Exception { assertTrue("Should include HOST scope", scopes.contains("HOST")); assertTrue("Should include FOLDER scope", scopes.contains("FOLDER")); } + + // ==================== PUT Asset Permissions Tests (updateAssetPermissions) ==================== + + /** + *

    + *
  • Method to test: {@link PermissionResource#updateAssetPermissions}
  • + *
  • Given Scenario: Admin user updates permissions for a host asset with + * a single role having READ, WRITE, and PUBLISH individual permissions.
  • + *
  • Expected Result: Permissions are saved successfully, response contains + * message, permissionCount, and asset with correct permissions.
  • + *
+ */ + @Test + public void test_updateAssetPermissions_basicHostUpdate_success() throws Exception { + HttpServletRequest request = mockRequest(); + + // Create form with single role having READ, WRITE, PUBLISH permissions + List permissions = new ArrayList<>(); + permissions.add(new RolePermissionForm( + testRole.getId(), + List.of("READ", "WRITE", "PUBLISH"), + null // no inheritable + )); + UpdateAssetPermissionsForm form = new UpdateAssetPermissionsForm(permissions); + + // Execute PUT + ResponseEntityUpdatePermissionsView response = resource.updateAssetPermissions( + request, this.response, updateTestHost.getIdentifier(), false, form + ); + + // Assert response + assertNotNull(response); + UpdateAssetPermissionsView data = response.getEntity(); + assertNotNull(data); + assertEquals("Permissions saved successfully", data.message()); + assertTrue("Permission count should be > 0", data.permissionCount() > 0); + + // Verify asset in response + AssetPermissionsView asset = data.asset(); + assertNotNull(asset); + assertEquals(updateTestHost.getIdentifier(), asset.assetId()); + assertEquals("HOST", asset.assetType()); + assertTrue("Host should be parent permissionable", asset.isParentPermissionable()); + } + + /** + *
    + *
  • Method to test: {@link PermissionResource#updateAssetPermissions}
  • + *
  • Given Scenario: Admin user updates permissions for a folder with + * both individual permissions and inheritable permissions for multiple scopes.
  • + *
  • Expected Result: Both individual and inheritable permissions are saved, + * response asset contains permissions with inheritable map.
  • + *
+ */ + @Test + public void test_updateAssetPermissions_multipleScopes_success() throws Exception { + HttpServletRequest request = mockRequest(); + + // Create form with individual and inheritable permissions + Map> inheritable = new HashMap<>(); + inheritable.put("FOLDER", List.of("READ", "CAN_ADD_CHILDREN")); + inheritable.put("CONTENT", List.of("READ", "WRITE")); + + List permissions = new ArrayList<>(); + permissions.add(new RolePermissionForm( + testRole.getId(), + List.of("READ", "WRITE"), + inheritable + )); + UpdateAssetPermissionsForm form = new UpdateAssetPermissionsForm(permissions); + + // Execute PUT on folder (parent permissionable) + ResponseEntityUpdatePermissionsView response = resource.updateAssetPermissions( + request, this.response, updateTestFolder.getInode(), false, form + ); + + // Assert response + assertNotNull(response); + UpdateAssetPermissionsView data = response.getEntity(); + assertNotNull(data); + assertEquals("Permissions saved successfully", data.message()); + assertTrue("Permission count should be > 0", data.permissionCount() > 0); + + // Verify asset is parent permissionable (can have inheritable) + AssetPermissionsView asset = data.asset(); + assertTrue("Folder should be parent permissionable", asset.isParentPermissionable()); + } + + /** + *
    + *
  • Method to test: {@link PermissionResource#updateAssetPermissions}
  • + *
  • Given Scenario: Admin user updates permissions on a child folder that + * currently inherits permissions from its parent folder.
  • + *
  • Expected Result: The permission inheritance is automatically broken, + * inheritanceBroken=true in response, and inheritanceMode is INDIVIDUAL.
  • + *
+ */ + @Test + public void test_updateAssetPermissions_breaksInheritance_success() throws Exception { + HttpServletRequest request = mockRequest(); + + // Create a new child folder that inherits for this test (to avoid test pollution) + Folder inheritingChild = new FolderDataGen() + .site(updateTestHost) + .parent(parentFolder) + .title("inheriting-child-" + System.currentTimeMillis()) + .nextPersisted(); + + // Reset to ensure it inherits + APILocator.getPermissionAPI().resetPermissionsUnder(parentFolder); + + // VERIFY inheritance before test (critical assertion) + assertTrue("Child folder should be inheriting before test", + APILocator.getPermissionAPI().isInheritingPermissions(inheritingChild)); + + // Create form + List permissions = new ArrayList<>(); + permissions.add(new RolePermissionForm( + testRole.getId(), + List.of("READ", "WRITE"), + null + )); + UpdateAssetPermissionsForm form = new UpdateAssetPermissionsForm(permissions); + + // Execute PUT on inheriting folder + ResponseEntityUpdatePermissionsView response = resource.updateAssetPermissions( + request, this.response, inheritingChild.getInode(), false, form + ); + + // Assert response + assertNotNull(response); + UpdateAssetPermissionsView data = response.getEntity(); + assertTrue("inheritanceBroken should be true", data.inheritanceBroken()); + assertEquals("INDIVIDUAL", data.asset().inheritanceMode()); + + // VERIFY inheritance broken after PUT (critical assertion) + assertFalse("Child folder should NOT be inheriting after PUT", + APILocator.getPermissionAPI().isInheritingPermissions(inheritingChild)); + } + + /** + *
    + *
  • Method to test: {@link UpdateAssetPermissionsForm#checkValid()}
  • + *
  • Given Scenario: A form is created with an empty permissions array.
  • + *
  • Expected Result: A BadRequestException is thrown during form validation + * indicating that permissions cannot be empty.
  • + *
+ */ + @Test + public void test_updateAssetPermissions_emptyPermissions_badRequest() throws Exception { + // Create form with empty permissions list + UpdateAssetPermissionsForm form = new UpdateAssetPermissionsForm(new ArrayList<>()); + + try { + form.checkValid(); + fail("Should have thrown BadRequestException for empty permissions"); + } catch (BadRequestException e) { + String entity = e.getResponse().getEntity().toString(); + assertTrue("Error message should contain 'empty'", + entity.toLowerCase().contains("empty")); + } + } + + /** + *
    + *
  • Method to test: {@link UpdateAssetPermissionsForm#checkValid()}
  • + *
  • Given Scenario: A form is created with an invalid permission scope + * in the inheritable map.
  • + *
  • Expected Result: A BadRequestException is thrown during form validation + * indicating the invalid scope.
  • + *
+ */ + @Test + public void test_updateAssetPermissions_invalidScope_badRequest() throws Exception { + // Create form with invalid scope + Map> inheritable = new HashMap<>(); + inheritable.put("INVALID_SCOPE", List.of("READ")); + + List permissions = new ArrayList<>(); + permissions.add(new RolePermissionForm( + testRole.getId(), + null, // no individual + inheritable + )); + UpdateAssetPermissionsForm form = new UpdateAssetPermissionsForm(permissions); + + try { + form.checkValid(); + fail("Should have thrown BadRequestException for invalid scope"); + } catch (BadRequestException e) { + String entity = e.getResponse().getEntity().toString(); + assertTrue("Error message should contain 'scope'", + entity.toLowerCase().contains("scope")); + } + } + + /** + *
    + *
  • Method to test: {@link UpdateAssetPermissionsForm#checkValid()}
  • + *
  • Given Scenario: A form is created with an invalid permission level.
  • + *
  • Expected Result: A BadRequestException is thrown during form validation + * indicating the invalid level.
  • + *
+ */ + @Test + public void test_updateAssetPermissions_invalidLevel_badRequest() throws Exception { + // Create form with invalid permission level + List permissions = new ArrayList<>(); + permissions.add(new RolePermissionForm( + testRole.getId(), + List.of("INVALID_LEVEL"), + null + )); + UpdateAssetPermissionsForm form = new UpdateAssetPermissionsForm(permissions); + + try { + form.checkValid(); + fail("Should have thrown BadRequestException for invalid level"); + } catch (BadRequestException e) { + String entity = e.getResponse().getEntity().toString(); + assertTrue("Error message should contain 'level'", + entity.toLowerCase().contains("level")); + } + } + + /** + *
    + *
  • Method to test: {@link UpdateAssetPermissionsForm#checkValid()}
  • + *
  • Given Scenario: A form is created with a permission entry missing roleId.
  • + *
  • Expected Result: A BadRequestException is thrown during form validation + * indicating roleId is required.
  • + *
+ */ + @Test + public void test_updateAssetPermissions_missingRoleId_badRequest() throws Exception { + // Create form with missing roleId + List permissions = new ArrayList<>(); + permissions.add(new RolePermissionForm( + null, // missing roleId + List.of("READ"), + null + )); + UpdateAssetPermissionsForm form = new UpdateAssetPermissionsForm(permissions); + + try { + form.checkValid(); + fail("Should have thrown BadRequestException for missing roleId"); + } catch (BadRequestException e) { + String entity = e.getResponse().getEntity().toString(); + assertTrue("Error message should contain 'roleId'", + entity.toLowerCase().contains("roleid")); + } + } + + /** + *
    + *
  • Method to test: {@link PermissionResource#updateAssetPermissions}
  • + *
  • Given Scenario: A non-admin user attempts to update asset permissions.
  • + *
  • Expected Result: A DotSecurityException is thrown indicating that only + * admin users can update asset permissions.
  • + *
+ */ + @Test + public void test_updateAssetPermissions_nonAdmin_forbidden() throws Exception { + // Setup request as limitedUser (non-admin) + MockHeaderRequest request = new MockHeaderRequest( + new MockSessionRequest( + new MockAttributeRequest( + new MockHttpRequestIntegrationTest(testHost.getHostname(), "/").request() + ).request() + ).request() + ); + + request.getSession().setAttribute(WebKeys.USER_ID, limitedUser.getUserId()); + request.getSession().setAttribute(WebKeys.USER, limitedUser); + request.getSession().setAttribute(com.dotmarketing.util.WebKeys.CURRENT_HOST, testHost); + request.getSession().setAttribute(com.dotmarketing.util.WebKeys.CMS_SELECTED_HOST_ID, testHost.getIdentifier()); + + // Create valid form + List permissions = new ArrayList<>(); + permissions.add(new RolePermissionForm( + testRole.getId(), + List.of("READ"), + null + )); + UpdateAssetPermissionsForm form = new UpdateAssetPermissionsForm(permissions); + + try { + resource.updateAssetPermissions( + request, this.response, updateTestHost.getIdentifier(), false, form + ); + fail("Should have thrown DotSecurityException"); + } catch (DotSecurityException e) { + assertTrue("Error message should indicate admin-only", + e.getMessage().toLowerCase().contains("admin")); + } + } } diff --git a/dotcms-postman/src/main/resources/postman/Permission_Resource.postman_collection.json b/dotcms-postman/src/main/resources/postman/Permission_Resource.postman_collection.json index 274439b10f07..b0f326b0a763 100644 --- a/dotcms-postman/src/main/resources/postman/Permission_Resource.postman_collection.json +++ b/dotcms-postman/src/main/resources/postman/Permission_Resource.postman_collection.json @@ -1528,6 +1528,599 @@ } ], "description": "Tests for GET /api/v1/permissions/user/{userId} endpoint" + }, + { + "name": "Asset Permissions (GET /permissions/{assetId})", + "item": [ + { + "name": "Setup: Create Test Folder", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code should be 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test(\"Folder created successfully\", function () {", + " var jsonData = pm.response.json();", + " pm.expect(jsonData.entity).to.be.an('array');", + " pm.expect(jsonData.entity.length).to.be.greaterThan(0);", + " ", + " var folderId = jsonData.entity[0].identifier;", + " pm.expect(folderId).to.be.a('string').and.not.eql('');", + " pm.collectionVariables.set('testAssetFolderId', folderId);", + " ", + " console.log('Created test folder with ID: ' + folderId);", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "admin@dotcms.com", + "type": "string" + }, + { + "key": "password", + "value": "admin", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "[\"/test_asset_permissions_folder\"]", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{serverURL}}/api/v1/folder/createfolders/default", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v1", + "folder", + "createfolders", + "default" + ] + } + }, + "response": [] + }, + { + "name": "Setup: Create Test Contentlet", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code should be 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test(\"Contentlet created successfully\", function () {", + " var jsonData = pm.response.json();", + " pm.expect(jsonData.errors.length).to.eql(0);", + " pm.expect(jsonData.entity.summary.affected).to.eql(1);", + " ", + " var saved = jsonData.entity.results[0];", + " var props = Object.keys(saved);", + " var identifierProp = props[0];", + " var contentlet = saved[identifierProp];", + " var contentletId = contentlet.identifier;", + " ", + " pm.expect(contentletId).to.be.a('string').and.not.eql('');", + " pm.collectionVariables.set('testAssetContentletId', contentletId);", + " ", + " console.log('Created test contentlet with identifier: ' + contentletId);", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "admin@dotcms.com", + "type": "string" + }, + { + "key": "password", + "value": "admin", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"contentlets\":[\n {\n \"contentType\":\"PermissionByContentTest\",\n \"title\":\"TestAssetPermissions\",\n \"contentHost\":\"default\"\n }\n ]\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{serverURL}}/api/v1/workflow/actions/default/fire/PUBLISH?indexPolicy=WAIT_FOR", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v1", + "workflow", + "actions", + "default", + "fire", + "PUBLISH" + ], + "query": [ + { + "key": "indexPolicy", + "value": "WAIT_FOR" + } + ] + } + }, + "response": [] + }, + { + "name": "Get Folder Permissions - Happy Path", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code should be 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test(\"Response has required structure\", function () {", + " var jsonData = pm.response.json().entity;", + " ", + " pm.expect(jsonData).to.have.property('assetId');", + " pm.expect(jsonData).to.have.property('assetType');", + " pm.expect(jsonData).to.have.property('inheritanceMode');", + " pm.expect(jsonData).to.have.property('isParentPermissionable');", + " pm.expect(jsonData).to.have.property('canEditPermissions');", + " pm.expect(jsonData).to.have.property('canEdit');", + " pm.expect(jsonData).to.have.property('permissions');", + "});", + "", + "pm.test(\"Asset type should be FOLDER\", function () {", + " var jsonData = pm.response.json().entity;", + " pm.expect(jsonData.assetType).to.eql('FOLDER');", + "});", + "", + "pm.test(\"Permissions array contains roles\", function () {", + " var permissions = pm.response.json().entity.permissions;", + " pm.expect(permissions).to.be.an('array');", + " pm.expect(permissions.length).to.be.greaterThan(0);", + "});", + "", + "pm.test(\"Each permission has expected structure\", function () {", + " var permissions = pm.response.json().entity.permissions;", + " ", + " permissions.forEach(permission => {", + " pm.expect(permission).to.have.property('roleId');", + " pm.expect(permission).to.have.property('roleName');", + " pm.expect(permission).to.have.property('inherited');", + " pm.expect(permission).to.have.property('individual');", + " pm.expect(permission).to.have.property('inheritable');", + " ", + " pm.expect(permission.individual).to.be.an('array');", + " // inheritable is either an object (for parent-permissionables) or null", + " if (permission.inheritable !== null) {", + " pm.expect(permission.inheritable).to.be.an('object');", + " }", + " });", + "});", + "", + "pm.test(\"Pagination metadata is valid\", function () {", + " var pagination = pm.response.json().pagination;", + " ", + " pm.expect(pagination).to.have.property('currentPage');", + " pm.expect(pagination).to.have.property('perPage');", + " pm.expect(pagination).to.have.property('totalEntries');", + " ", + " pm.expect(pagination.currentPage).to.be.a('number').and.eql(1);", + " pm.expect(pagination.perPage).to.be.a('number').and.eql(40);", + " pm.expect(pagination.totalEntries).to.be.a('number').and.greaterThan(0);", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "admin@dotcms.com", + "type": "string" + }, + { + "key": "password", + "value": "admin", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "{{serverURL}}/api/v1/permissions/{{testAssetFolderId}}", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v1", + "permissions", + "{{testAssetFolderId}}" + ] + } + }, + "response": [] + }, + { + "name": "Get Contentlet Permissions - Happy Path", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code should be 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test(\"Response has required structure\", function () {", + " var jsonData = pm.response.json().entity;", + " ", + " pm.expect(jsonData).to.have.property('assetId');", + " pm.expect(jsonData).to.have.property('assetType');", + " pm.expect(jsonData).to.have.property('inheritanceMode');", + " pm.expect(jsonData).to.have.property('isParentPermissionable');", + " pm.expect(jsonData).to.have.property('canEditPermissions');", + " pm.expect(jsonData).to.have.property('canEdit');", + " pm.expect(jsonData).to.have.property('permissions');", + "});", + "", + "pm.test(\"Asset type should be CONTENT\", function () {", + " var jsonData = pm.response.json().entity;", + " pm.expect(jsonData.assetType).to.eql('CONTENT');", + "});", + "", + "pm.test(\"Contentlet has no inheritable permissions\", function () {", + " var permissions = pm.response.json().entity.permissions;", + " ", + " permissions.forEach(permission => {", + " pm.expect(permission.inheritable).to.be.null;", + " });", + "});", + "", + "pm.test(\"Permissions array contains roles\", function () {", + " var permissions = pm.response.json().entity.permissions;", + " pm.expect(permissions).to.be.an('array');", + " pm.expect(permissions.length).to.be.greaterThan(0);", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "admin@dotcms.com", + "type": "string" + }, + { + "key": "password", + "value": "admin", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "{{serverURL}}/api/v1/permissions/{{testAssetContentletId}}", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v1", + "permissions", + "{{testAssetContentletId}}" + ] + } + }, + "response": [] + }, + { + "name": "Get Permissions with Custom Pagination", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code should be 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test(\"Pagination reflects requested parameters\", function () {", + " var pagination = pm.response.json().pagination;", + " ", + " pm.expect(pagination.currentPage).to.eql(1);", + " pm.expect(pagination.perPage).to.eql(10);", + "});", + "", + "pm.test(\"Permissions array respects per_page limit\", function () {", + " var permissions = pm.response.json().entity.permissions;", + " pm.expect(permissions.length).to.be.at.most(10);", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "admin@dotcms.com", + "type": "string" + }, + { + "key": "password", + "value": "admin", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "{{serverURL}}/api/v1/permissions/{{testAssetFolderId}}?page=1&per_page=10", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v1", + "permissions", + "{{testAssetFolderId}}" + ], + "query": [ + { + "key": "page", + "value": "1" + }, + { + "key": "per_page", + "value": "10" + } + ] + } + }, + "response": [] + }, + { + "name": "Get Max Per Page (100)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code should be 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test(\"Pagination reflects max per_page\", function () {", + " var pagination = pm.response.json().pagination;", + " pm.expect(pagination.perPage).to.eql(100);", + "});", + "", + "pm.test(\"Permissions array respects max per_page limit\", function () {", + " var permissions = pm.response.json().entity.permissions;", + " pm.expect(permissions.length).to.be.at.most(100);", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "admin@dotcms.com", + "type": "string" + }, + { + "key": "password", + "value": "admin", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "{{serverURL}}/api/v1/permissions/{{testAssetFolderId}}?per_page=100", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v1", + "permissions", + "{{testAssetFolderId}}" + ], + "query": [ + { + "key": "per_page", + "value": "100" + } + ] + } + }, + "response": [] + }, + { + "name": "Asset Not Found - Expect 404", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code should be 404\", function () {", + " pm.response.to.have.status(404);", + "});", + "", + "pm.test(\"Response contains error message\", function () {", + " var jsonData = pm.response.json();", + " pm.expect(jsonData).to.have.property('message');", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "admin@dotcms.com", + "type": "string" + }, + { + "key": "password", + "value": "admin", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "{{serverURL}}/api/v1/permissions/nonexistent-asset-id-12345", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v1", + "permissions", + "nonexistent-asset-id-12345" + ] + } + }, + "response": [] + }, + { + "name": "Logout Before Unauthorized Test", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 200\", function () {", + " pm.response.to.have.status(200);", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{serverURL}}/api/v1/logout", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v1", + "logout" + ] + } + }, + "response": [] + }, + { + "name": "Unauthorized Access - Expect 401", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code should be 401\", function () {", + " pm.response.to.have.status(401);", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "noauth" + }, + "method": "GET", + "header": [], + "url": { + "raw": "{{serverURL}}/api/v1/permissions/{{testAssetFolderId}}", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v1", + "permissions", + "{{testAssetFolderId}}" + ] + } + }, + "response": [] + } + ] } ], "variable": [ @@ -1582,6 +2175,14 @@ { "key": "adminHasSystemHost", "value": "" + }, + { + "key": "testAssetFolderId", + "value": "" + }, + { + "key": "testAssetContentletId", + "value": "" } ] } \ No newline at end of file