From 0d3efd0a6a322873b4738f5dae35196f4352d42e Mon Sep 17 00:00:00 2001 From: hassandotcms Date: Fri, 21 Nov 2025 19:14:07 +0500 Subject: [PATCH 1/3] modernize(permissions): rest api for get asset permissions (#33835) 1. New GET /permissions/{assetId} endpoint - View asset permissions with pagination, supporting all permissionable types (folders, hosts, contentlets, etc.) 2. Permission helper infrastructure - Added AssetPermissionHelper for building responses and ResponseEntityAssetPermissionsView for typed API responses, integrated via CDI 3. Documentation and tests - OpenAPI spec updates and comprehensive Postman test suite covering happy paths, pagination, validation, and error cases --- .../dotcms/business/APILocatorProducers.java | 6 + .../permission/AssetPermissionHelper.java | 538 ++++++++++++ .../system/permission/PermissionResource.java | 123 ++- .../ResponseEntityAssetPermissionsView.java | 28 + .../main/webapp/WEB-INF/openapi/openapi.yaml | 83 ++ ...ermission_Resource.postman_collection.json | 788 ++++++++++++++++++ 6 files changed, 1565 insertions(+), 1 deletion(-) create mode 100644 dotCMS/src/main/java/com/dotcms/rest/api/v1/system/permission/AssetPermissionHelper.java create mode 100644 dotCMS/src/main/java/com/dotcms/rest/api/v1/system/permission/ResponseEntityAssetPermissionsView.java 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/AssetPermissionHelper.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/permission/AssetPermissionHelper.java new file mode 100644 index 000000000000..ced53b8de15c --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/permission/AssetPermissionHelper.java @@ -0,0 +1,538 @@ +package com.dotcms.rest.api.v1.system.permission; + +import com.dotcms.contenttype.exception.NotFoundInDbException; +import com.dotmarketing.beans.Host; +import com.dotmarketing.beans.Identifier; +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.categories.model.Category; +import com.dotmarketing.portlets.containers.model.Container; +import com.dotmarketing.portlets.contentlet.business.HostAPI; +import com.dotmarketing.portlets.contentlet.model.Contentlet; +import com.dotmarketing.portlets.folders.business.FolderAPI; +import com.dotmarketing.portlets.folders.model.Folder; +import com.dotmarketing.portlets.htmlpageasset.model.IHTMLPage; +import com.dotmarketing.portlets.links.model.Link; +import com.dotmarketing.portlets.rules.model.Rule; +import com.dotmarketing.portlets.structure.model.Structure; +import com.dotmarketing.portlets.templates.design.bean.TemplateLayout; +import com.dotmarketing.portlets.templates.model.Template; +import com.dotmarketing.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 Hassan + * @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; + } + + /** + * Builds complete permission response for an asset with pagination. + * + * @param assetId Asset identifier (inode or identifier) + * @param page Page number (1-indexed) + * @param perPage Number of roles per page + * @param requestingUser User making the request (for permission checks) + * @return AssetPermissionResponse containing entity and pagination at root level + * @throws DotDataException If there's an error accessing permission data + * @throws DotSecurityException If security validation fails + * @throws NotFoundInDbException If asset is not found + */ + public AssetPermissionResponse buildAssetPermissionResponse(final String assetId, + final int page, + final int perPage, + final User requestingUser) + throws DotDataException, DotSecurityException { + + Logger.debug(this, () -> String.format( + "Building asset permission response for assetId: %s, page: %d, perPage: %d", + assetId, page, perPage)); + + final Permissionable asset = 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)); + } + + final User systemUser = APILocator.getUserAPI().getSystemUser(); + + // Build entity data (asset metadata + permissions) + final Map entity = new HashMap<>(); + entity.putAll(getAssetMetadata(asset, requestingUser, systemUser)); + + // Get all permissions for this asset + final List permissions = permissionAPI.getPermissions(asset, true); + Logger.debug(this, () -> String.format( + "Retrieved %d permissions for asset: %s", permissions.size(), assetId)); + + // Build and paginate role permissions + final List> rolePermissions = buildRolePermissions( + permissions, asset, requestingUser); + + final PaginatedResult paginatedData = paginateRoles(rolePermissions, page, perPage); + + entity.put("permissions", paginatedData.permissions); + + Logger.info(this, () -> String.format( + "Successfully built permission response for asset: %s", assetId)); + + return new AssetPermissionResponse(entity, paginatedData.pagination); + } + + /** + * 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 + */ + @VisibleForTesting + protected 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-level metadata fields for the response. + * + * @param asset The permissionable asset + * @param requestingUser User making the request + * @param systemUser System user for permission checks + * @return Map with asset metadata fields + * @throws DotDataException If there's an error accessing data + * @throws DotSecurityException If security validation fails + */ + @VisibleForTesting + protected Map getAssetMetadata(final Permissionable asset, + final User requestingUser, + final User systemUser) + throws DotDataException, DotSecurityException { + + final Map metadata = new HashMap<>(); + + metadata.put("assetId", asset.getPermissionId()); + metadata.put("assetType", getAssetType(asset)); + metadata.put("inheritanceMode", + permissionAPI.isInheritingPermissions(asset) ? "INHERITED" : "INDIVIDUAL"); + metadata.put("isParentPermissionable", asset.isParentPermissionable()); + + metadata.put("canEditPermissions", + permissionAPI.doesUserHavePermission( + asset, PermissionAPI.PERMISSION_EDIT_PERMISSIONS, requestingUser, false)); + + metadata.put("canEdit", + permissionAPI.doesUserHavePermission( + asset, PermissionAPI.PERMISSION_WRITE, requestingUser, false)); + + // Get parent asset ID if exists + try { + final Permissionable parent = permissionAPI.findParentPermissionable(asset); + if (parent != null) { + metadata.put("parentAssetId", parent.getPermissionId()); + } else { + metadata.put("parentAssetId", null); + } + } catch (Exception e) { + Logger.debug(this, () -> "No parent permissionable found for asset"); + metadata.put("parentAssetId", null); + } + + return metadata; + } + + /** + * Builds permission data grouped by role. + * + * @param permissions List of permissions for the asset + * @param asset The permissionable asset + * @param requestingUser User making the request + * @return List of role permission maps + * @throws DotDataException If there's an error accessing data + */ + @VisibleForTesting + protected List> buildRolePermissions(final List permissions, + final Permissionable asset, + final User requestingUser) + throws DotDataException { + + 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; + } + + final Map rolePermission = new HashMap<>(); + rolePermission.put("roleId", roleId); + rolePermission.put("roleName", role.getName()); + rolePermission.put("inherited", isInheriting); + + // 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 + rolePermission.put("individual", + convertPermissionsToStringArray(individualPermissions)); + + // Build inheritable permissions map (only for parent permissionables) + if (isParentPermissionable && !inheritablePermissions.isEmpty()) { + rolePermission.put("inheritable", + buildInheritablePermissionMap(inheritablePermissions)); + } else { + rolePermission.put("inheritable", null); + } + + rolePermissions.add(rolePermission); + + } catch (DotDataException e) { + Logger.warn(this, String.format("Error loading role: %s - %s", + roleId, e.getMessage())); + } + } + + return rolePermissions; + } + + /** + * Holds paginated permissions and pagination metadata. + */ + @VisibleForTesting + protected static class PaginatedResult { + final List> permissions; + final com.dotcms.rest.Pagination pagination; + + PaginatedResult(final List> permissions, + final com.dotcms.rest.Pagination pagination) { + this.permissions = permissions; + this.pagination = pagination; + } + } + + /** + * Holds the complete response with entity and pagination at root level. + */ + public static class AssetPermissionResponse { + public final Map entity; + public final com.dotcms.rest.Pagination pagination; + + public AssetPermissionResponse(final Map entity, + final com.dotcms.rest.Pagination pagination) { + this.entity = entity; + this.pagination = pagination; + } + } + + /** + * Paginates the role permissions list and builds pagination metadata. + * + * @param rolePermissions Complete list of role permissions + * @param page Page number (1-indexed) + * @param perPage Number of roles per page + * @return PaginatedResult with permissions list and Pagination object + */ + @VisibleForTesting + protected PaginatedResult paginateRoles(final List> rolePermissions, + final int page, + final int perPage) { + + final int totalEntries = rolePermissions.size(); + final int startIndex = (page - 1) * perPage; + final int endIndex = Math.min(startIndex + perPage, totalEntries); + + final List> paginatedPermissions; + if (startIndex >= totalEntries) { + paginatedPermissions = new ArrayList<>(); + } else { + paginatedPermissions = rolePermissions.subList(startIndex, endIndex); + } + + final com.dotcms.rest.Pagination pagination = new com.dotcms.rest.Pagination.Builder() + .currentPage(page) + .perPage(perPage) + .totalEntries(totalEntries) + .build(); + + return new PaginatedResult(paginatedPermissions, pagination); + } + + /** + * 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. + * Avoids duplicate aliases (e.g., USE=READ, EDIT=WRITE). + * + * @param permissionBits Bit-packed permission value + * @return List of permission level strings + */ + private 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; + } + + /** + * Maps internal permission type class names to modern API type constants. + */ + private 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") + ); + + /** + * Gets the modern API type name for a permission type. + * + * @param permissionType Internal permission type (class name or scope) + * @return Modern API type constant + */ + private 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(this, () -> String.format("Unknown permission type: %s", permissionType)); + return permissionType.toUpperCase(); + } + + /** + * Gets the asset type string for the response. + * Maps Permissionable types to API type constants (uppercase enum). + * + * @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; + } + + final String permissionType = asset.getPermissionType(); + final String modernType = getModernPermissionType(permissionType); + + // Return uppercase for asset type enum + return modernType; + } +} 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 99a7cfbbd43e..0268a9cbe39a 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,6 +8,7 @@ import com.dotmarketing.beans.Permission; import com.dotmarketing.business.APILocator; import com.dotmarketing.business.PermissionAPI; +import com.dotmarketing.business.Permissionable; import com.dotmarketing.business.UserAPI; import com.dotmarketing.exception.DotDataException; import com.dotmarketing.exception.DotSecurityException; @@ -31,6 +32,7 @@ import javax.ws.rs.DefaultValue; import javax.ws.rs.GET; import javax.ws.rs.Path; +import javax.ws.rs.PathParam; import javax.ws.rs.Produces; import javax.ws.rs.QueryParam; import javax.ws.rs.core.Context; @@ -55,19 +57,23 @@ public class PermissionResource { private final WebResource webResource; private final PermissionHelper permissionHelper; + private final AssetPermissionHelper assetPermissionHelper; private final UserAPI userAPI; public PermissionResource() { - this(new WebResource(), PermissionHelper.getInstance(), APILocator.getUserAPI()); + this(new WebResource(), PermissionHelper.getInstance(), + new AssetPermissionHelper(), APILocator.getUserAPI()); } @VisibleForTesting public PermissionResource(final WebResource webResource, final PermissionHelper permissionHelper, + final AssetPermissionHelper assetPermissionHelper, final UserAPI userAPI) { this.webResource = webResource; this.permissionHelper = permissionHelper; + this.assetPermissionHelper = assetPermissionHelper; this.userAPI = userAPI; } @@ -280,4 +286,119 @@ public static PermissionView from(Permission permission) { PermissionAPI.Type.findById(permission.getPermission()), permission.isBitPermission(), permission.getType()); 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 = ResponseEntityAssetPermissionsView.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 ResponseEntityAssetPermissionsView 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 pagination parameters + if (page < 1) { + Logger.warn(this, String.format("Invalid page number: %d (must be >= 1)", page)); + throw new IllegalArgumentException("Invalid page number: must be >= 1"); + } + + if (perPage < 1 || perPage > 100) { + Logger.warn(this, String.format("Invalid per_page: %d (must be between 1 and 100)", perPage)); + throw new IllegalArgumentException("Invalid per_page: must be between 1 and 100"); + } + + if (!UtilMethods.isSet(assetId)) { + Logger.warn(this, "Asset ID is required but was not provided"); + throw new IllegalArgumentException("Asset ID is required"); + } + + // Verify user has READ permission on the asset + // This is a viewing operation, not a management operation, so any user with READ access can view permissions + final PermissionAPI permissionAPI = APILocator.getPermissionAPI(); + + // First, resolve the asset to verify it exists and user has access + 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 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"); + } + + // User has READ permission, proceed with building the response + final AssetPermissionHelper.AssetPermissionResponse permissionResponse = assetPermissionHelper + .buildAssetPermissionResponse(assetId, page, perPage, user); + + Logger.info(this, () -> String.format( + "Successfully retrieved permissions for asset: %s", assetId)); + + return new ResponseEntityAssetPermissionsView(permissionResponse.entity, permissionResponse.pagination); + } } 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..6197814d9dc3 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/permission/ResponseEntityAssetPermissionsView.java @@ -0,0 +1,28 @@ +package com.dotcms.rest.api.v1.system.permission; + +import com.dotcms.rest.Pagination; +import com.dotcms.rest.ResponseEntityView; + +import java.util.Map; + +/** + * Response wrapper for asset permissions endpoint. + * Used by PermissionResource.getAssetPermissions() to return paginated + * permission data organized by roles for a specific asset. + * + * @author Hassan + * @since 24.01 + */ +public class ResponseEntityAssetPermissionsView extends ResponseEntityView> { + + /** + * Constructor for asset permissions response with pagination. + * Places pagination at root level alongside entity. + * + * @param entity Map containing asset metadata and paginated permissions array + * @param pagination Pagination metadata (currentPage, perPage, totalEntries) + */ + public ResponseEntityAssetPermissionsView(final Map entity, final Pagination pagination) { + super(entity, pagination); + } +} diff --git a/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml b/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml index 74505b65103d..325109180df3 100644 --- a/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml +++ b/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml @@ -10600,6 +10600,64 @@ paths: summary: Get permissions by permission type 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/ResponseEntityAssetPermissionsView" + 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 /v1/personalization/pagepersonas: post: description: Copies the current content associated to page containers with default @@ -23405,6 +23463,31 @@ components: type: array items: type: string + ResponseEntityAssetPermissionsView: + type: object + properties: + entity: + type: object + additionalProperties: + type: object + 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 ResponseEntityBooleanView: type: object properties: 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 701b1f3a6b52..a030abd0661b 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 @@ -1038,6 +1038,786 @@ "description": "Get only write and couple types" }, "response": [] + }, + { + "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 Last Page Beyond Total", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code should be 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test(\"Permissions array should be empty for page beyond total\", function () {", + " var permissions = pm.response.json().entity.permissions;", + " pm.expect(permissions).to.be.an('array');", + " pm.expect(permissions.length).to.eql(0);", + "});", + "", + "pm.test(\"Pagination metadata remains consistent\", function () {", + " var pagination = pm.response.json().pagination;", + " ", + " pm.expect(pagination.currentPage).to.eql(999);", + " 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}}?page=999", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v1", + "permissions", + "{{testAssetFolderId}}" + ], + "query": [ + { + "key": "page", + "value": "999" + } + ] + } + }, + "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": "Invalid Page Parameter - Expect 400", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code should be 400\", function () {", + " pm.response.to.have.status(400);", + "});", + "", + "pm.test(\"Response contains error message\", function () {", + " var jsonData = pm.response.json();", + " pm.expect(jsonData).to.have.property('message');", + " pm.expect(jsonData.message).to.include('page');", + "});" + ], + "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=0", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v1", + "permissions", + "{{testAssetFolderId}}" + ], + "query": [ + { + "key": "page", + "value": "0" + } + ] + } + }, + "response": [] + }, + { + "name": "Invalid PerPage Parameter - Expect 400", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code should be 400\", function () {", + " pm.response.to.have.status(400);", + "});", + "", + "pm.test(\"Response contains error message\", function () {", + " var jsonData = pm.response.json();", + " pm.expect(jsonData).to.have.property('message');", + " pm.expect(jsonData.message).to.include('per_page');", + "});" + ], + "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=101", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v1", + "permissions", + "{{testAssetFolderId}}" + ], + "query": [ + { + "key": "per_page", + "value": "101" + } + ] + } + }, + "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": [ @@ -1080,6 +1860,14 @@ { "key": "loginAsRoles", "value": "" + }, + { + "key": "testAssetFolderId", + "value": "" + }, + { + "key": "testAssetContentletId", + "value": "" } ] } From eb4c95b459e8e635b6716add2b70ef29e282cf55 Mon Sep 17 00:00:00 2001 From: hassandotcms Date: Tue, 25 Nov 2025 07:52:09 +0500 Subject: [PATCH 2/3] modernize(permissions): rest api to update asset permission (#33912) - PUT /api/v1/permissions/{assetId} - REST endpoint to save/update asset permissions (admin-only) - Auto-breaks inheritance when saving on inheriting asset, supports ?cascade=true for async propagation - Returns message, permissionCount, inheritanceBroken, and updated asset object --- .../permission/AssetPermissionHelper.java | 330 +++++++++++++--- .../permission/PermissionConversionUtils.java | 209 +++++++++++ .../system/permission/PermissionResource.java | 95 +++++ .../ResponseEntityUpdatePermissionsView.java | 44 +++ .../system/permission/RolePermissionForm.java | 80 ++++ .../UpdateAssetPermissionsForm.java | 61 +++ .../api/v1/user/UserPermissionHelper.java | 66 +--- .../main/webapp/WEB-INF/openapi/openapi.yaml | 104 ++++++ .../PermissionResourceIntegrationTest.java | 351 ++++++++++++++++++ 9 files changed, 1232 insertions(+), 108 deletions(-) create mode 100644 dotCMS/src/main/java/com/dotcms/rest/api/v1/system/permission/PermissionConversionUtils.java create mode 100644 dotCMS/src/main/java/com/dotcms/rest/api/v1/system/permission/ResponseEntityUpdatePermissionsView.java create mode 100644 dotCMS/src/main/java/com/dotcms/rest/api/v1/system/permission/RolePermissionForm.java create mode 100644 dotCMS/src/main/java/com/dotcms/rest/api/v1/system/permission/UpdateAssetPermissionsForm.java create mode 100644 dotcms-integration/src/test/java/com/dotcms/rest/api/v1/system/permission/PermissionResourceIntegrationTest.java diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/permission/AssetPermissionHelper.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/permission/AssetPermissionHelper.java index ced53b8de15c..4e6545a1a9ca 100644 --- a/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/permission/AssetPermissionHelper.java +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/permission/AssetPermissionHelper.java @@ -2,7 +2,6 @@ import com.dotcms.contenttype.exception.NotFoundInDbException; import com.dotmarketing.beans.Host; -import com.dotmarketing.beans.Identifier; import com.dotmarketing.beans.Permission; import com.dotmarketing.business.APILocator; import com.dotmarketing.business.PermissionAPI; @@ -11,18 +10,13 @@ import com.dotmarketing.business.RoleAPI; import com.dotmarketing.exception.DotDataException; import com.dotmarketing.exception.DotSecurityException; -import com.dotmarketing.portlets.categories.model.Category; import com.dotmarketing.portlets.containers.model.Container; import com.dotmarketing.portlets.contentlet.business.HostAPI; import com.dotmarketing.portlets.contentlet.model.Contentlet; import com.dotmarketing.portlets.folders.business.FolderAPI; import com.dotmarketing.portlets.folders.model.Folder; -import com.dotmarketing.portlets.htmlpageasset.model.IHTMLPage; -import com.dotmarketing.portlets.links.model.Link; -import com.dotmarketing.portlets.rules.model.Rule; -import com.dotmarketing.portlets.structure.model.Structure; -import com.dotmarketing.portlets.templates.design.bean.TemplateLayout; import com.dotmarketing.portlets.templates.model.Template; +import com.dotmarketing.quartz.job.CascadePermissionsJob; import com.dotmarketing.util.Logger; import com.dotmarketing.util.UtilMethods; import com.google.common.annotations.VisibleForTesting; @@ -452,69 +446,24 @@ private Map> buildInheritablePermissionMap( /** * Converts permission bits to permission level names. - * Avoids duplicate aliases (e.g., USE=READ, EDIT=WRITE). + * Delegates to {@link PermissionConversionUtils#convertBitsToPermissionNames(int)}. * * @param permissionBits Bit-packed permission value * @return List of permission level strings */ private 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; + return PermissionConversionUtils.convertBitsToPermissionNames(permissionBits); } - /** - * Maps internal permission type class names to modern API type constants. - */ - private 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") - ); - /** * 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) { - if (!UtilMethods.isSet(permissionType)) { - return StringPool.BLANK; - } - - final String mappedType = PERMISSION_TYPE_MAPPINGS.get(permissionType.toUpperCase()); - if (mappedType != null) { - return mappedType; - } - - Logger.debug(this, () -> String.format("Unknown permission type: %s", permissionType)); - return permissionType.toUpperCase(); + return PermissionConversionUtils.getModernPermissionType(permissionType); } /** @@ -535,4 +484,273 @@ private String getAssetType(final Permissionable asset) { // Return uppercase for asset type enum return modernType; } + + // ======================================================================== + // 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 Response map containing message, permissionCount, inheritanceBroken, and asset + * @throws DotDataException If there's an error accessing data + * @throws DotSecurityException If security validation fails + */ + public Map 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 response map 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 Response map with message, permissionCount, inheritanceBroken, and asset + * @throws DotDataException If there's an error accessing data + * @throws DotSecurityException If security validation fails + */ + private Map buildUpdateResponse(final Permissionable asset, + final User user, + final boolean inheritanceBroken, + final int permissionCount) + throws DotDataException, DotSecurityException { + + final Map response = new HashMap<>(); + response.put("message", "Permissions saved successfully"); + response.put("permissionCount", permissionCount); + response.put("inheritanceBroken", inheritanceBroken); + + // Build asset object + final User systemUser = APILocator.getUserAPI().getSystemUser(); + final Map assetData = getAssetMetadata(asset, user, systemUser); + + // Get updated permissions for the asset + final List permissions = permissionAPI.getPermissions(asset, true); + final List> rolePermissions = buildRolePermissions(permissions, asset, user); + assetData.put("permissions", rolePermissions); + + response.put("asset", assetData); + + return response; + } } 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 0268a9cbe39a..89269d80e2b3 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 @@ -29,8 +29,10 @@ import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; +import javax.ws.rs.Consumes; import javax.ws.rs.DefaultValue; import javax.ws.rs.GET; +import javax.ws.rs.PUT; import javax.ws.rs.Path; import javax.ws.rs.PathParam; import javax.ws.rs.Produces; @@ -38,6 +40,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; @@ -401,4 +405,95 @@ public ResponseEntityAssetPermissionsView getAssetPermissions( return new ResponseEntityAssetPermissionsView(permissionResponse.entity, permissionResponse.pagination); } + + /** + * 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"); + } + + // Delegate to helper for business logic + final java.util.Map 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/ResponseEntityUpdatePermissionsView.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/permission/ResponseEntityUpdatePermissionsView.java new file mode 100644 index 000000000000..230422dbf77b --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/permission/ResponseEntityUpdatePermissionsView.java @@ -0,0 +1,44 @@ +package com.dotcms.rest.api.v1.system.permission; + +import com.dotcms.rest.ResponseEntityView; + +import java.util.Map; + +/** + * 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 Map containing message, permissionCount, inheritanceBroken, and asset data + */ + public ResponseEntityUpdatePermissionsView(final Map 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..ee8281a9a2dd --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/permission/RolePermissionForm.java @@ -0,0 +1,80 @@ +package com.dotcms.rest.api.v1.system.permission; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; + +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 + */ +public class RolePermissionForm { + + private final String roleId; + private final List individual; + 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..003f25a021ae --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/permission/UpdateAssetPermissionsForm.java @@ -0,0 +1,61 @@ +package com.dotcms.rest.api.v1.system.permission; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; + +/** + * 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 { + + 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; + } +} diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/user/UserPermissionHelper.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/user/UserPermissionHelper.java index 85a353b762fb..1eb3a0f58182 100644 --- a/dotCMS/src/main/java/com/dotcms/rest/api/v1/user/UserPermissionHelper.java +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/user/UserPermissionHelper.java @@ -10,19 +10,9 @@ import com.dotmarketing.business.UserAPI; import com.dotmarketing.exception.DotDataException; import com.dotmarketing.exception.DotSecurityException; -import com.dotmarketing.portlets.categories.model.Category; -import com.dotmarketing.portlets.containers.model.Container; import com.dotmarketing.portlets.contentlet.business.HostAPI; -import com.dotmarketing.portlets.contentlet.model.Contentlet; import com.dotmarketing.portlets.folders.business.FolderAPI; import com.dotmarketing.portlets.folders.model.Folder; -import com.dotmarketing.portlets.htmlpageasset.model.IHTMLPage; -import com.dotmarketing.portlets.links.model.Link; -import com.dotmarketing.portlets.rules.model.Rule; -import com.dotmarketing.portlets.structure.model.Structure; -import com.dotmarketing.portlets.templates.design.bean.TemplateLayout; -import com.dotmarketing.portlets.templates.model.Template; -import com.dotmarketing.util.Logger; import com.dotmarketing.util.UtilMethods; import com.liferay.portal.model.User; import com.liferay.util.StringPool; @@ -209,54 +199,26 @@ public Map> buildPermissionMap(final List permi } /** - * Maps permission type class names to API type constants. + * Gets the modern API type name for a permission type. + * Delegates to {@link com.dotcms.rest.api.v1.system.permission.PermissionConversionUtils}. + * + * @param permissionType Internal permission type (class name or scope) + * @return Modern API type constant */ - private 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(), "STRUCTURE"), - 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") - ); - private String getModernPermissionType(final String permissionType) { - final String mappedType = PERMISSION_TYPE_MAPPINGS.get(permissionType.toUpperCase()); - if (mappedType != null) { - return mappedType; - } - Logger.debug(this, "Unknown permission type: " + permissionType); - return permissionType.toUpperCase(); + return com.dotcms.rest.api.v1.system.permission.PermissionConversionUtils + .getModernPermissionType(permissionType); } /** - * Avoids duplicate aliases like USE/read or EDIT/WRITE + * Converts permission bits to permission level names. + * Delegates to {@link com.dotcms.rest.api.v1.system.permission.PermissionConversionUtils}. + * + * @param permissionBits Bit-packed permission value + * @return List of permission level strings */ private 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; + return com.dotcms.rest.api.v1.system.permission.PermissionConversionUtils + .convertBitsToPermissionNames(permissionBits); } } \ No newline at end of file diff --git a/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml b/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml index b244d36a9382..ebb774ea6a0a 100644 --- a/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml +++ b/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml @@ -10734,6 +10734,63 @@ paths: 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 @@ -24726,6 +24783,31 @@ components: type: array items: type: string + ResponseEntityUpdatePermissionsView: + type: object + properties: + entity: + type: object + additionalProperties: + type: object + 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 ResponseEntityUserPermissionsView: type: object properties: @@ -25720,6 +25802,21 @@ components: uniqueItems: true roleId: type: string + RolePermissionForm: + type: object + properties: + individual: + type: array + items: + type: string + inheritable: + type: object + additionalProperties: + type: array + items: + type: string + roleId: + type: string RoleResponseEntityView: type: object properties: @@ -26919,6 +27016,13 @@ components: count: type: integer format: int64 + UpdateAssetPermissionsForm: + type: object + properties: + permissions: + type: array + items: + $ref: "#/components/schemas/RolePermissionForm" 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 new file mode 100644 index 000000000000..ce3ebe8f02e0 --- /dev/null +++ b/dotcms-integration/src/test/java/com/dotcms/rest/api/v1/system/permission/PermissionResourceIntegrationTest.java @@ -0,0 +1,351 @@ +package com.dotcms.rest.api.v1.system.permission; + +import com.dotcms.datagen.FolderDataGen; +import com.dotcms.datagen.RoleDataGen; +import com.dotcms.datagen.SiteDataGen; +import com.dotcms.datagen.TestUserUtils; +import com.dotcms.datagen.UserDataGen; +import com.dotcms.mock.request.MockAttributeRequest; +import com.dotcms.mock.request.MockHeaderRequest; +import com.dotcms.mock.request.MockHttpRequestIntegrationTest; +import com.dotcms.mock.request.MockSessionRequest; +import com.dotcms.mock.response.MockHttpResponse; +import com.dotcms.util.IntegrationTestInitService; +import com.dotmarketing.beans.Host; +import com.dotmarketing.business.APILocator; +import com.dotmarketing.business.PermissionAPI; +import com.dotmarketing.business.Role; +import com.dotmarketing.business.RoleAPI; +import com.dotmarketing.exception.DotDataException; +import com.dotmarketing.exception.DotSecurityException; +import com.dotmarketing.portlets.folders.model.Folder; +import com.liferay.portal.model.User; +import com.liferay.util.Base64; +import org.junit.BeforeClass; +import org.junit.Test; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.*; + +/** + * Integration tests for the PermissionResource PUT endpoint. + * Tests the update asset permissions functionality. + * + * @author dotCMS + * @since 24.01 + */ +public class PermissionResourceIntegrationTest { + + private static HttpServletResponse response; + private static PermissionResource resource; + private static PermissionAPI permissionAPI; + private static RoleAPI roleAPI; + private static User adminUser; + private static Host testSite; + + @BeforeClass + public static void prepare() throws Exception { + IntegrationTestInitService.getInstance().init(); + response = new MockHttpResponse(); + resource = new PermissionResource(); + permissionAPI = APILocator.getPermissionAPI(); + roleAPI = APILocator.getRoleAPI(); + adminUser = TestUserUtils.getAdminUser(); + testSite = new SiteDataGen().nextPersisted(); + } + + private HttpServletRequest getHttpRequest(final String userEmail, final String password) { + final String userEmailAndPassword = userEmail + ":" + password; + final MockHeaderRequest request = new MockHeaderRequest( + new MockSessionRequest( + new MockAttributeRequest(new MockHttpRequestIntegrationTest("localhost", "/").request()) + .request()) + .request()); + + request.setHeader("Authorization", + "Basic " + new String(Base64.encode(userEmailAndPassword.getBytes()))); + + return request; + } + + /** + * Method to test: updateAssetPermissions in the PermissionResource + * Given Scenario: Update folder permissions with valid admin user and role + * ExpectedResult: Permissions saved successfully, response contains message, permissionCount, inheritanceBroken, and asset + */ + @Test + public void test_updateAssetPermissions_success() throws DotDataException, DotSecurityException { + // Create a test folder + final Folder testFolder = new FolderDataGen().site(testSite).nextPersisted(); + + // Create a test role + final Role testRole = new RoleDataGen().nextPersisted(); + + // Build the form + final RolePermissionForm rolePermissionForm = new RolePermissionForm( + testRole.getId(), + Arrays.asList("READ", "WRITE", "PUBLISH"), + null + ); + final UpdateAssetPermissionsForm form = new UpdateAssetPermissionsForm( + Arrays.asList(rolePermissionForm) + ); + + // Call the endpoint + final ResponseEntityUpdatePermissionsView responseView = resource.updateAssetPermissions( + getHttpRequest(adminUser.getEmailAddress(), "admin"), + response, + testFolder.getInode(), + false, + form + ); + + // Verify response + assertNotNull("Response should not be null", responseView); + final Map entity = responseView.getEntity(); + assertNotNull("Entity should not be null", entity); + + // Verify response fields + assertEquals("Permissions saved successfully", entity.get("message")); + assertNotNull("permissionCount should be present", entity.get("permissionCount")); + assertNotNull("inheritanceBroken should be present", entity.get("inheritanceBroken")); + assertNotNull("asset should be present", entity.get("asset")); + + // Verify asset has permissions + final Map asset = (Map) entity.get("asset"); + assertNotNull("asset.permissions should be present", asset.get("permissions")); + assertEquals("INDIVIDUAL", asset.get("inheritanceMode")); + } + + /** + * Method to test: updateAssetPermissions in the PermissionResource + * Given Scenario: Non-admin user tries to update permissions + * ExpectedResult: DotSecurityException is thrown with appropriate message + */ + @Test(expected = DotSecurityException.class) + public void test_updateAssetPermissions_nonAdminUser_throws403() throws DotDataException, DotSecurityException { + // Create test data + final Folder testFolder = new FolderDataGen().site(testSite).nextPersisted(); + final Role testRole = new RoleDataGen().nextPersisted(); + + // Create a non-admin user with backend role (so they can authenticate) but NOT admin + // Must use a known plaintext password and assign frontend/backend roles for authentication + final String knownPassword = "testPassword123"; + final User nonAdminUser = new UserDataGen() + .password(knownPassword) + .roles(TestUserUtils.getFrontendRole(), TestUserUtils.getBackendRole()) + .nextPersisted(); + + // Build the form + final RolePermissionForm rolePermissionForm = new RolePermissionForm( + testRole.getId(), + Arrays.asList("READ"), + null + ); + final UpdateAssetPermissionsForm form = new UpdateAssetPermissionsForm( + Arrays.asList(rolePermissionForm) + ); + + // Call the endpoint - should throw DotSecurityException because user is not admin + resource.updateAssetPermissions( + getHttpRequest(nonAdminUser.getEmailAddress(), knownPassword), + response, + testFolder.getInode(), + false, + form + ); + } + + /** + * Method to test: updateAssetPermissions in the PermissionResource + * Given Scenario: Invalid role ID in the request + * ExpectedResult: IllegalArgumentException is thrown + */ + @Test(expected = IllegalArgumentException.class) + public void test_updateAssetPermissions_invalidRoleId_throws400() throws DotDataException, DotSecurityException { + // Create a test folder + final Folder testFolder = new FolderDataGen().site(testSite).nextPersisted(); + + // Build form with invalid role ID + final RolePermissionForm rolePermissionForm = new RolePermissionForm( + "invalid-role-id-12345", + Arrays.asList("READ"), + null + ); + final UpdateAssetPermissionsForm form = new UpdateAssetPermissionsForm( + Arrays.asList(rolePermissionForm) + ); + + // Call the endpoint - should throw IllegalArgumentException + resource.updateAssetPermissions( + getHttpRequest(adminUser.getEmailAddress(), "admin"), + response, + testFolder.getInode(), + false, + form + ); + } + + /** + * Method to test: updateAssetPermissions in the PermissionResource + * Given Scenario: Asset ID does not exist + * ExpectedResult: NotFoundInDbException is thrown + */ + @Test(expected = com.dotcms.contenttype.exception.NotFoundInDbException.class) + public void test_updateAssetPermissions_assetNotFound_throws404() throws DotDataException, DotSecurityException { + // Create a test role + final Role testRole = new RoleDataGen().nextPersisted(); + + // Build the form + final RolePermissionForm rolePermissionForm = new RolePermissionForm( + testRole.getId(), + Arrays.asList("READ"), + null + ); + final UpdateAssetPermissionsForm form = new UpdateAssetPermissionsForm( + Arrays.asList(rolePermissionForm) + ); + + // Call with non-existent asset ID + resource.updateAssetPermissions( + getHttpRequest(adminUser.getEmailAddress(), "admin"), + response, + "non-existent-asset-id-12345", + false, + form + ); + } + + /** + * Method to test: updateAssetPermissions in the PermissionResource + * Given Scenario: Update permissions on an inheriting folder + * ExpectedResult: inheritanceBroken should be true in response + */ + @Test + public void test_updateAssetPermissions_breaksInheritance() throws DotDataException, DotSecurityException { + // Create a parent folder and child folder (child inherits by default) + final Folder parentFolder = new FolderDataGen().site(testSite).nextPersisted(); + final Folder childFolder = new FolderDataGen().parent(parentFolder).nextPersisted(); + + // Verify child is inheriting + assertTrue("Child folder should initially inherit permissions", + permissionAPI.isInheritingPermissions(childFolder)); + + // Create a test role + final Role testRole = new RoleDataGen().nextPersisted(); + + // Build the form + final RolePermissionForm rolePermissionForm = new RolePermissionForm( + testRole.getId(), + Arrays.asList("READ", "WRITE"), + null + ); + final UpdateAssetPermissionsForm form = new UpdateAssetPermissionsForm( + Arrays.asList(rolePermissionForm) + ); + + // Update permissions on child folder + final ResponseEntityUpdatePermissionsView responseView = resource.updateAssetPermissions( + getHttpRequest(adminUser.getEmailAddress(), "admin"), + response, + childFolder.getInode(), + false, + form + ); + + // Verify response + final Map entity = responseView.getEntity(); + assertTrue("inheritanceBroken should be true", (Boolean) entity.get("inheritanceBroken")); + + // Verify folder is no longer inheriting + assertFalse("Child folder should no longer inherit permissions", + permissionAPI.isInheritingPermissions(childFolder)); + } + + /** + * Method to test: updateAssetPermissions in the PermissionResource + * Given Scenario: Update permissions with inheritable permissions on a folder + * ExpectedResult: Both individual and inheritable permissions are saved + */ + @Test + public void test_updateAssetPermissions_withInheritablePermissions() throws DotDataException, DotSecurityException { + // Create a test folder + final Folder testFolder = new FolderDataGen().site(testSite).nextPersisted(); + + // Create a test role + final Role testRole = new RoleDataGen().nextPersisted(); + + // Build form with both individual and inheritable permissions + final Map> inheritable = new HashMap<>(); + inheritable.put("FOLDER", Arrays.asList("READ", "CAN_ADD_CHILDREN")); + inheritable.put("CONTENT", Arrays.asList("READ", "WRITE", "PUBLISH")); + + final RolePermissionForm rolePermissionForm = new RolePermissionForm( + testRole.getId(), + Arrays.asList("READ", "WRITE", "PUBLISH", "EDIT_PERMISSIONS"), + inheritable + ); + final UpdateAssetPermissionsForm form = new UpdateAssetPermissionsForm( + Arrays.asList(rolePermissionForm) + ); + + // Call the endpoint + final ResponseEntityUpdatePermissionsView responseView = resource.updateAssetPermissions( + getHttpRequest(adminUser.getEmailAddress(), "admin"), + response, + testFolder.getInode(), + false, + form + ); + + // Verify response + final Map entity = responseView.getEntity(); + assertEquals("Permissions saved successfully", entity.get("message")); + + // Permission count should include individual + inheritable + final int permissionCount = (Integer) entity.get("permissionCount"); + assertTrue("Permission count should be > 1 (individual + inheritable)", permissionCount > 1); + + // Verify asset permissions in response + final Map asset = (Map) entity.get("asset"); + final List> permissions = (List>) asset.get("permissions"); + assertFalse("Permissions should not be empty", permissions.isEmpty()); + } + + /** + * Method to test: updateAssetPermissions in the PermissionResource + * Given Scenario: Invalid permission level name + * ExpectedResult: IllegalArgumentException is thrown + */ + @Test(expected = IllegalArgumentException.class) + public void test_updateAssetPermissions_invalidPermissionLevel_throws400() throws DotDataException, DotSecurityException { + // Create test data + final Folder testFolder = new FolderDataGen().site(testSite).nextPersisted(); + final Role testRole = new RoleDataGen().nextPersisted(); + + // Build form with invalid permission level + final RolePermissionForm rolePermissionForm = new RolePermissionForm( + testRole.getId(), + Arrays.asList("READ", "INVALID_PERMISSION"), + null + ); + final UpdateAssetPermissionsForm form = new UpdateAssetPermissionsForm( + Arrays.asList(rolePermissionForm) + ); + + // Call the endpoint - should throw IllegalArgumentException + resource.updateAssetPermissions( + getHttpRequest(adminUser.getEmailAddress(), "admin"), + response, + testFolder.getInode(), + false, + form + ); + } +} From 23eb43545459a6c615af85db9ae296ca8d540b72 Mon Sep 17 00:00:00 2001 From: hassandotcms Date: Tue, 25 Nov 2025 08:28:31 +0500 Subject: [PATCH 3/3] modernize(permissions): api to reset asset permission (#33914) - Reset API: PUT /api/v1/permissions/{assetId}/_reset removes individual permissions, making asset inherit from parent - Idempotency: Returns 409 Conflict if asset already inherits; includes previousPermissionCount in response - Admin-only: Restricted to admin users for safety --- .../permission/AssetPermissionHelper.java | 72 +++ .../system/permission/PermissionResource.java | 81 ++++ .../ResponseEntityResetPermissionsView.java | 34 ++ .../main/webapp/WEB-INF/openapi/openapi.yaml | 68 +++ .../PermissionResourceIntegrationTest.java | 143 ++++++ ...ermission_Resource.postman_collection.json | 452 ++++++++++++++++++ 6 files changed, 850 insertions(+) create mode 100644 dotCMS/src/main/java/com/dotcms/rest/api/v1/system/permission/ResponseEntityResetPermissionsView.java diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/permission/AssetPermissionHelper.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/permission/AssetPermissionHelper.java index 4e6545a1a9ca..3577e534474c 100644 --- a/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/permission/AssetPermissionHelper.java +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/permission/AssetPermissionHelper.java @@ -1,6 +1,7 @@ package com.dotcms.rest.api.v1.system.permission; import com.dotcms.contenttype.exception.NotFoundInDbException; +import com.dotcms.rest.exception.ConflictException; import com.dotmarketing.beans.Host; import com.dotmarketing.beans.Permission; import com.dotmarketing.business.APILocator; @@ -753,4 +754,75 @@ private Map buildUpdateResponse(final Permissionable asset, return response; } + + // ======================================================================== + // RESET ASSET PERMISSIONS METHODS + // ======================================================================== + + /** + * Resets permissions for an asset to inherit from its parent. + * Removes all individual permissions from the asset, making it inherit permissions. + * + * @param assetId Asset identifier (inode or identifier) + * @param user Requesting user (must be admin) + * @return Response map containing message, assetId, and previousPermissionCount + * @throws DotDataException If there's an error accessing data + * @throws DotSecurityException If security validation fails + * @throws NotFoundInDbException If asset is not found + * @throws ConflictException If asset already inherits permissions (409) + */ + public Map resetAssetPermissions(final String assetId, final User user) + throws DotDataException, DotSecurityException { + + Logger.debug(this, () -> String.format( + "resetAssetPermissions - assetId: %s, user: %s", assetId, user.getUserId())); + + // 1. Validate asset ID + if (!UtilMethods.isSet(assetId)) { + throw new IllegalArgumentException("Asset ID is required"); + } + + // 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 already inheriting - return 409 Conflict + if (permissionAPI.isInheritingPermissions(asset)) { + Logger.debug(this, () -> String.format( + "Asset already inherits permissions: %s", assetId)); + throw new ConflictException("Asset already inherits permissions from parent"); + } + + // 5. Get count of individual permissions before removal + final List individualPermissions = permissionAPI.getPermissions(asset, true, true); + final int previousPermissionCount = individualPermissions.size(); + + Logger.debug(this, () -> String.format( + "Removing %d individual permissions from asset: %s", previousPermissionCount, assetId)); + + // 6. Remove all individual permissions - asset will now inherit + permissionAPI.removePermissions(asset); + + // 7. Build and return response + Logger.info(this, () -> String.format( + "Successfully reset permissions for asset: %s (removed %d permissions)", + assetId, previousPermissionCount)); + + final Map response = new HashMap<>(); + response.put("message", "Individual permissions removed. Asset now inherits from parent."); + response.put("assetId", assetId); + response.put("previousPermissionCount", previousPermissionCount); + + return response; + } } 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 89269d80e2b3..238736358531 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 @@ -496,4 +496,85 @@ public ResponseEntityUpdatePermissionsView updateAssetPermissions( return new ResponseEntityUpdatePermissionsView(result); } + + /** + * Resets permissions for a specific asset to inherit from its parent. + * This operation removes all individual permissions from the asset, making it + * inherit permissions from its parent in the hierarchy. + * + * @param request HTTP servlet request + * @param response HTTP servlet response + * @param assetId Asset identifier (inode or identifier) + * @return ResponseEntityResetPermissionsView containing operation result + * @throws DotDataException If there's an error accessing permission data + * @throws DotSecurityException If security validation fails + */ + @Operation( + summary = "Reset asset permissions to inherited", + description = "Removes all individual permissions from an asset, making it inherit " + + "permissions from its parent in the hierarchy. Only admin users can " + + "access this endpoint. Returns 409 Conflict if the asset already inherits." + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", + description = "Permissions reset successfully", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ResponseEntityResetPermissionsView.class))), + @ApiResponse(responseCode = "400", + description = "Bad request - invalid asset ID", + 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", + content = @Content(mediaType = "application/json")), + @ApiResponse(responseCode = "404", + description = "Asset not found", + content = @Content(mediaType = "application/json")), + @ApiResponse(responseCode = "409", + description = "Conflict - asset already inherits permissions from parent", + content = @Content(mediaType = "application/json")) + }) + @PUT + @Path("/{assetId}/_reset") + @JSONP + @NoCache + @Produces({MediaType.APPLICATION_JSON}) + public ResponseEntityResetPermissionsView resetAssetPermissions( + final @Context HttpServletRequest request, + final @Context HttpServletResponse response, + @Parameter(description = "Asset identifier (inode or identifier)", required = true) + final @PathParam("assetId") String assetId) + throws DotDataException, DotSecurityException { + + Logger.debug(this, () -> String.format( + "resetAssetPermissions called - assetId: %s", assetId)); + + // 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 reset permissions for asset: %s", + user.getUserId(), assetId)); + throw new DotSecurityException("Only admin users can reset asset permissions"); + } + + // Delegate to helper for business logic + final java.util.Map result = assetPermissionHelper.resetAssetPermissions( + assetId, user); + + Logger.info(this, () -> String.format( + "Successfully reset permissions for asset: %s", assetId)); + + return new ResponseEntityResetPermissionsView(result); + } } diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/permission/ResponseEntityResetPermissionsView.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/permission/ResponseEntityResetPermissionsView.java new file mode 100644 index 000000000000..4c663b5949ab --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/permission/ResponseEntityResetPermissionsView.java @@ -0,0 +1,34 @@ +package com.dotcms.rest.api.v1.system.permission; + +import com.dotcms.rest.ResponseEntityView; + +import java.util.Map; + +/** + * Response entity view for the PUT /api/v1/permissions/{assetId}/_reset endpoint. + * + *

Response structure: + *

{@code
+ * {
+ *   "entity": {
+ *     "message": "Individual permissions removed. Asset now inherits from parent.",
+ *     "assetId": "asset-123",
+ *     "previousPermissionCount": 5
+ *   }
+ * }
+ * }
+ * + * @author dotCMS + * @since 24.01 + */ +public class ResponseEntityResetPermissionsView extends ResponseEntityView> { + + /** + * Creates a new ResponseEntityResetPermissionsView. + * + * @param entity Map containing message, assetId, and previousPermissionCount + */ + public ResponseEntityResetPermissionsView(final Map entity) { + super(entity); + } +} diff --git a/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml b/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml index ebb774ea6a0a..c1a714e0bc93 100644 --- a/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml +++ b/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml @@ -10791,6 +10791,49 @@ paths: summary: Update asset permissions tags: - Permissions + /v1/permissions/{assetId}/_reset: + put: + description: "Removes all individual permissions from an asset, making it inherit\ + \ permissions from its parent in the hierarchy. Only admin users can access\ + \ this endpoint. Returns 409 Conflict if the asset already inherits." + operationId: resetAssetPermissions + parameters: + - description: Asset identifier (inode or identifier) + in: path + name: assetId + required: true + schema: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/ResponseEntityResetPermissionsView" + description: Permissions reset successfully + "400": + content: + application/json: {} + description: Bad request - invalid asset ID + "401": + content: + application/json: {} + description: Unauthorized - authentication required + "403": + content: + application/json: {} + description: Forbidden - user is not admin + "404": + content: + application/json: {} + description: Asset not found + "409": + content: + application/json: {} + description: Conflict - asset already inherits permissions from parent + summary: Reset asset permissions to inherited + tags: + - Permissions /v1/personalization/pagepersonas: post: description: Copies the current content associated to page containers with default @@ -24443,6 +24486,31 @@ components: type: array items: type: string + ResponseEntityResetPermissionsView: + type: object + properties: + entity: + type: object + additionalProperties: + type: object + 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 ResponseEntityRestTagListView: 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 ce3ebe8f02e0..99dfada0deb8 100644 --- a/dotcms-integration/src/test/java/com/dotcms/rest/api/v1/system/permission/PermissionResourceIntegrationTest.java +++ b/dotcms-integration/src/test/java/com/dotcms/rest/api/v1/system/permission/PermissionResourceIntegrationTest.java @@ -5,6 +5,7 @@ import com.dotcms.datagen.SiteDataGen; import com.dotcms.datagen.TestUserUtils; import com.dotcms.datagen.UserDataGen; +import com.dotcms.rest.exception.ConflictException; import com.dotcms.mock.request.MockAttributeRequest; import com.dotcms.mock.request.MockHeaderRequest; import com.dotcms.mock.request.MockHttpRequestIntegrationTest; @@ -348,4 +349,146 @@ public void test_updateAssetPermissions_invalidPermissionLevel_throws400() throw form ); } + + // ======================================================================== + // RESET ASSET PERMISSIONS TESTS + // ======================================================================== + + /** + * Method to test: resetAssetPermissions in the PermissionResource + * Given Scenario: Reset permissions on a folder with individual permissions + * ExpectedResult: Permissions reset successfully, folder now inherits + */ + @Test + public void test_resetAssetPermissions_success() throws DotDataException, DotSecurityException { + // Create parent and child folder + final Folder parentFolder = new FolderDataGen().site(testSite).nextPersisted(); + final Folder childFolder = new FolderDataGen().parent(parentFolder).nextPersisted(); + + // Create a test role and add individual permissions to child folder + final Role testRole = new RoleDataGen().nextPersisted(); + final RolePermissionForm rolePermissionForm = new RolePermissionForm( + testRole.getId(), + Arrays.asList("READ", "WRITE"), + null + ); + final UpdateAssetPermissionsForm form = new UpdateAssetPermissionsForm( + Arrays.asList(rolePermissionForm) + ); + + // First, set individual permissions on child folder + resource.updateAssetPermissions( + getHttpRequest(adminUser.getEmailAddress(), "admin"), + response, + childFolder.getInode(), + false, + form + ); + + // Verify child has individual permissions + assertFalse("Child folder should have individual permissions", + permissionAPI.isInheritingPermissions(childFolder)); + + // Now reset the permissions + final ResponseEntityResetPermissionsView responseView = resource.resetAssetPermissions( + getHttpRequest(adminUser.getEmailAddress(), "admin"), + response, + childFolder.getInode() + ); + + // Verify response + assertNotNull("Response should not be null", responseView); + final Map entity = responseView.getEntity(); + assertNotNull("Entity should not be null", entity); + + // Verify response fields + assertEquals("Individual permissions removed. Asset now inherits from parent.", + entity.get("message")); + assertEquals(childFolder.getInode(), entity.get("assetId")); + assertNotNull("previousPermissionCount should be present", entity.get("previousPermissionCount")); + assertTrue("previousPermissionCount should be >= 0", + (Integer) entity.get("previousPermissionCount") >= 0); + + // Verify folder is now inheriting + assertTrue("Child folder should now inherit permissions", + permissionAPI.isInheritingPermissions(childFolder)); + } + + /** + * Method to test: resetAssetPermissions in the PermissionResource + * Given Scenario: Non-admin user tries to reset permissions + * ExpectedResult: DotSecurityException is thrown + */ + @Test(expected = DotSecurityException.class) + public void test_resetAssetPermissions_nonAdminUser_throws403() throws DotDataException, DotSecurityException { + // Create test folder + final Folder testFolder = new FolderDataGen().site(testSite).nextPersisted(); + + // Create a non-admin user with backend role + final String knownPassword = "testPassword456"; + final User nonAdminUser = new UserDataGen() + .password(knownPassword) + .roles(TestUserUtils.getFrontendRole(), TestUserUtils.getBackendRole()) + .nextPersisted(); + + // Call the endpoint - should throw DotSecurityException + resource.resetAssetPermissions( + getHttpRequest(nonAdminUser.getEmailAddress(), knownPassword), + response, + testFolder.getInode() + ); + } + + /** + * Method to test: resetAssetPermissions in the PermissionResource + * Given Scenario: Asset ID does not exist + * ExpectedResult: NotFoundInDbException is thrown + */ + @Test(expected = com.dotcms.contenttype.exception.NotFoundInDbException.class) + public void test_resetAssetPermissions_assetNotFound_throws404() throws DotDataException, DotSecurityException { + // Call with non-existent asset ID + resource.resetAssetPermissions( + getHttpRequest(adminUser.getEmailAddress(), "admin"), + response, + "non-existent-asset-id-67890" + ); + } + + /** + * Method to test: resetAssetPermissions in the PermissionResource + * Given Scenario: Asset already inherits permissions + * ExpectedResult: ConflictException is thrown (409 Conflict) + */ + @Test(expected = ConflictException.class) + public void test_resetAssetPermissions_alreadyInheriting_throws409() throws DotDataException, DotSecurityException { + // Create parent and child folder (child inherits by default) + final Folder parentFolder = new FolderDataGen().site(testSite).nextPersisted(); + final Folder childFolder = new FolderDataGen().parent(parentFolder).nextPersisted(); + + // Verify child is inheriting + assertTrue("Child folder should initially inherit permissions", + permissionAPI.isInheritingPermissions(childFolder)); + + // Try to reset - should throw ConflictException since already inheriting + resource.resetAssetPermissions( + getHttpRequest(adminUser.getEmailAddress(), "admin"), + response, + childFolder.getInode() + ); + } + + /** + * Method to test: resetAssetPermissions in the PermissionResource + * Given Scenario: Reset with empty/null asset ID + * ExpectedResult: IllegalArgumentException is thrown + */ + @Test(expected = IllegalArgumentException.class) + public void test_resetAssetPermissions_emptyAssetId_throws400() throws DotDataException, DotSecurityException { + // Call with empty asset ID - should throw IllegalArgumentException + resource.resetAssetPermissions( + getHttpRequest(adminUser.getEmailAddress(), "admin"), + response, + "" + ); + } } 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 a030abd0661b..2883725bd2fb 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 @@ -1818,6 +1818,450 @@ "response": [] } ] + }, + { + "name": "Reset Asset Permissions (PUT /permissions/{assetId}/_reset)", + "item": [ + { + "name": "Setup: Create Test Folder for Reset", + "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('resetTestFolderId', folderId);", + " ", + " console.log('Created test folder for reset 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_reset_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: Get Roles for Permissions", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code should be 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "", + "var jsonData = pm.response.json();", + "let roles = jsonData.entity;", + "pm.collectionVariables.set(\"resetTestRoleId\", roles[0].id);", + "console.log('Using role ID: ' + roles[0].id);" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "password", + "value": "admin", + "type": "string" + }, + { + "key": "username", + "value": "admin@dotcms.com", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "{{serverURL}}/api/v1/roles/_search", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v1", + "roles", + "_search" + ] + } + }, + "response": [] + }, + { + "name": "Setup: Add Individual Permissions to Folder", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code should be 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test(\"Permissions saved successfully\", function () {", + " var jsonData = pm.response.json();", + " pm.expect(jsonData.entity.message).to.eql('Permissions saved successfully');", + " pm.expect(jsonData.entity.inheritanceBroken).to.eql(true);", + "});", + "", + "console.log('Individual permissions added to folder');" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "admin@dotcms.com", + "type": "string" + }, + { + "key": "password", + "value": "admin", + "type": "string" + } + ] + }, + "method": "PUT", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"permissions\": [\n {\n \"roleId\": \"{{resetTestRoleId}}\",\n \"individual\": [\"READ\", \"WRITE\"]\n }\n ]\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{serverURL}}/api/v1/permissions/{{resetTestFolderId}}", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v1", + "permissions", + "{{resetTestFolderId}}" + ] + } + }, + "response": [] + }, + { + "name": "Reset 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('message');", + " pm.expect(jsonData).to.have.property('assetId');", + " pm.expect(jsonData).to.have.property('previousPermissionCount');", + "});", + "", + "pm.test(\"Message indicates success\", function () {", + " var jsonData = pm.response.json().entity;", + " pm.expect(jsonData.message).to.eql('Individual permissions removed. Asset now inherits from parent.');", + "});", + "", + "pm.test(\"AssetId matches request\", function () {", + " var jsonData = pm.response.json().entity;", + " pm.expect(jsonData.assetId).to.eql(pm.collectionVariables.get('resetTestFolderId'));", + "});", + "", + "pm.test(\"previousPermissionCount is a number >= 0\", function () {", + " var jsonData = pm.response.json().entity;", + " pm.expect(jsonData.previousPermissionCount).to.be.a('number');", + " pm.expect(jsonData.previousPermissionCount).to.be.at.least(0);", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "admin@dotcms.com", + "type": "string" + }, + { + "key": "password", + "value": "admin", + "type": "string" + } + ] + }, + "method": "PUT", + "header": [], + "url": { + "raw": "{{serverURL}}/api/v1/permissions/{{resetTestFolderId}}/_reset", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v1", + "permissions", + "{{resetTestFolderId}}", + "_reset" + ] + } + }, + "response": [] + }, + { + "name": "Reset Permissions - Already Inheriting (409 Conflict)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code should be 409\", function () {", + " pm.response.to.have.status(409);", + "});", + "", + "pm.test(\"Response contains conflict message\", function () {", + " var jsonData = pm.response.json();", + " pm.expect(jsonData).to.have.property('message');", + " pm.expect(jsonData.message.toLowerCase()).to.include('already');", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "admin@dotcms.com", + "type": "string" + }, + { + "key": "password", + "value": "admin", + "type": "string" + } + ] + }, + "method": "PUT", + "header": [], + "url": { + "raw": "{{serverURL}}/api/v1/permissions/{{resetTestFolderId}}/_reset", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v1", + "permissions", + "{{resetTestFolderId}}", + "_reset" + ] + } + }, + "response": [] + }, + { + "name": "Reset Permissions - Asset Not Found (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": "PUT", + "header": [], + "url": { + "raw": "{{serverURL}}/api/v1/permissions/nonexistent-asset-id-99999/_reset", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v1", + "permissions", + "nonexistent-asset-id-99999", + "_reset" + ] + } + }, + "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": "Reset Permissions - Unauthorized (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": "PUT", + "header": [], + "url": { + "raw": "{{serverURL}}/api/v1/permissions/{{resetTestFolderId}}/_reset", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v1", + "permissions", + "{{resetTestFolderId}}", + "_reset" + ] + } + }, + "response": [] + } + ] } ], "variable": [ @@ -1868,6 +2312,14 @@ { "key": "testAssetContentletId", "value": "" + }, + { + "key": "resetTestFolderId", + "value": "" + }, + { + "key": "resetTestRoleId", + "value": "" } ] }