diff --git a/server/src/main/java/com/epam/aidial/core/server/controller/ComplexResourceMetadataController.java b/server/src/main/java/com/epam/aidial/core/server/controller/ComplexResourceMetadataController.java index b92ab1606..df503e5f6 100644 --- a/server/src/main/java/com/epam/aidial/core/server/controller/ComplexResourceMetadataController.java +++ b/server/src/main/java/com/epam/aidial/core/server/controller/ComplexResourceMetadataController.java @@ -1,5 +1,6 @@ package com.epam.aidial.core.server.controller; +import com.epam.aidial.core.config.ResourceAccessType; import com.epam.aidial.core.openapi.annotations.ApiExtension; import com.epam.aidial.core.openapi.annotations.ApiOperation; import com.epam.aidial.core.openapi.annotations.ApiParameter; @@ -9,6 +10,7 @@ import com.epam.aidial.core.server.Proxy; import com.epam.aidial.core.server.ProxyContext; import com.epam.aidial.core.server.service.resource.ComplexResourceService; +import com.epam.aidial.core.server.util.ResourceDescriptorFactory; import com.epam.aidial.core.storage.data.ComplexResourceItemMetadata; import com.epam.aidial.core.storage.data.MetadataBase; import com.epam.aidial.core.storage.data.ResourceFolderMetadata; @@ -18,6 +20,8 @@ import io.vertx.core.http.HttpHeaders; import lombok.extern.slf4j.Slf4j; +import java.util.Set; + /** * Metadata listing for the v2 complex resource API. In children mode it lists the classified, enriched * immediate children of a grouping level; in files mode it lists the files of a resource's current version. @@ -130,4 +134,47 @@ protected Future handle(ResourceDescriptor resource, boolean hasWriteAccess) return Future.succeededFuture(); } + + /** + * Entry point for the children-listing metadata route, where the target path may name either a + * specific skill or a grouping folder - the client has no way to know which up front, so a share + * could be recorded against either shape. Both candidates are checked and their permissions unioned; + * the item-shaped resource is used for the actual lookup afterward, since + * {@link ComplexResourceService#getMetadata} resolves the real shape itself via the marker check. + */ + public Future handle(String itemResourceUrl, String folderResourceUrl) { + ResourceDescriptor itemResource; + ResourceDescriptor folderResource; + + try { + itemResource = ResourceDescriptorFactory.fromAnyUrl(itemResourceUrl, proxy.getEncryptionService()); + folderResource = ResourceDescriptorFactory.fromAnyUrl(folderResourceUrl, proxy.getEncryptionService()); + } catch (Exception e) { + String errorMessage = e.getMessage() != null ? e.getMessage() : ("Invalid resource url provided: " + itemResourceUrl); + return context.respond(HttpStatus.BAD_REQUEST, errorMessage); + } + + ResourceDescriptor finalItemResource = itemResource; + ResourceDescriptor finalFolderResource = folderResource; + Set resourcesToCheck = itemResource.equals(folderResource) + ? Set.of(itemResource) + : Set.of(itemResource, folderResource); + + return proxy.getTaskExecutor() + .submit(() -> proxy.getAccessService().lookupPermissions(resourcesToCheck, context)) + .compose(permissions -> { + Set itemPermissions = permissions.getOrDefault(finalItemResource, Set.of()); + Set folderPermissions = permissions.getOrDefault(finalFolderResource, Set.of()); + boolean hasAccess = itemPermissions.contains(ResourceAccessType.READ) + || folderPermissions.contains(ResourceAccessType.READ); + if (hasAccess) { + boolean hasWriteAccess = itemPermissions.contains(ResourceAccessType.WRITE) + || folderPermissions.contains(ResourceAccessType.WRITE); + return handle(finalItemResource, hasWriteAccess); + } else { + context.respond(HttpStatus.FORBIDDEN, "You don't have an access to: " + itemResourceUrl); + return Future.succeededFuture(); + } + }); + } } diff --git a/server/src/main/java/com/epam/aidial/core/server/controller/ControllerSelector.java b/server/src/main/java/com/epam/aidial/core/server/controller/ControllerSelector.java index 67ebbaea0..6e4ca1c07 100644 --- a/server/src/main/java/com/epam/aidial/core/server/controller/ControllerSelector.java +++ b/server/src/main/java/com/epam/aidial/core/server/controller/ControllerSelector.java @@ -154,7 +154,7 @@ public class ControllerSelector { }); get(RouteTemplate.COMPLEX_RESOURCE_METADATA, (proxy, context, pathMatcher) -> { ComplexResourceMetadataController controller = new ComplexResourceMetadataController(proxy, context, false, null); - return () -> controller.handle(complexResourceFolderUrl(pathMatcher)); + return () -> controller.handle(complexResourceUrl(pathMatcher), complexResourceFolderUrl(pathMatcher)); }); get(RouteTemplate.RESOURCE_FOLDER, (proxy, context, pathMatcher) -> { ComplexResourceController controller = new ComplexResourceController(proxy, context, false, true); @@ -718,8 +718,11 @@ private static String complexResourceUrl(Matcher matcher) { return "skills/" + matcher.group("bucket") + "/" + matcher.group("path"); } - // Builds the grouping-folder url (trailing slash so fromAnyUrl marks it a folder) for folder ops and the - // children metadata listing. An empty path lists the bucket root. + // Builds the grouping-folder url (trailing slash so fromAnyUrl marks it a folder) for folder ops, and + // as the metadata route's second access-check candidate: {path} may name either a specific skill (shared + // as the non-folder url from complexResourceUrl) or a grouping folder (shared as this folder url), and + // the client has no way to know which up front, so both shapes are checked. An empty path addresses the + // bucket root. private static String complexResourceFolderUrl(Matcher matcher) { String path = matcher.group("path"); if (path != null && path.endsWith("/")) { diff --git a/server/src/main/java/com/epam/aidial/core/server/service/resource/ComplexResourceService.java b/server/src/main/java/com/epam/aidial/core/server/service/resource/ComplexResourceService.java index d46074008..2aef402fa 100644 --- a/server/src/main/java/com/epam/aidial/core/server/service/resource/ComplexResourceService.java +++ b/server/src/main/java/com/epam/aidial/core/server/service/resource/ComplexResourceService.java @@ -565,7 +565,16 @@ public MetadataBase getMetadata(ResourceDescriptor resource, String token, int l if (marker != null) { return itemMetadata(resource, marker); } - return listChildren(resource, token, limit, recursive); + return listChildren(asFolder(resource), token, limit, recursive); + } + + // The v2 metadata route's target path may be requested without a trailing slash regardless of + // whether it names an item or a grouping folder, since the caller can't know which it is in advance. + // Once getMarker rules out an item, the resource must be folder-shaped for listChildren. + private static ResourceDescriptor asFolder(ResourceDescriptor resource) { + return resource.isFolder() ? resource + : new ResourceDescriptor(resource.getType(), resource.getName(), resource.getParentFolders(), + resource.getBucketName(), resource.getBucketLocation(), true); } private static ResourceItemMetadata itemMetadata(ResourceDescriptor resource, FolderResourceMarker marker) { diff --git a/server/src/test/java/com/epam/aidial/core/server/SkillResourceApiTest.java b/server/src/test/java/com/epam/aidial/core/server/SkillResourceApiTest.java index fc19aee80..a4fb2aea0 100644 --- a/server/src/test/java/com/epam/aidial/core/server/SkillResourceApiTest.java +++ b/server/src/test/java/com/epam/aidial/core/server/SkillResourceApiTest.java @@ -631,6 +631,35 @@ void testRevokeSharedAccess() { assertEquals(403, getSkillFile("/revoke-skill", "SKILL.md", "Api-key", "proxyKey2").status()); } + @Test + void testItemShareInheritsMetadataAccess() { + Map files = Map.of("SKILL.md", VALID_MANIFEST.getBytes(StandardCharsets.UTF_8)); + verify(uploadSkill("/shared-item/skill-a", files), 200); + + // before sharing, an unrelated user has no access to the item's metadata + assertEquals(403, listMetadata("shared-item/skill-a", "Api-key", "proxyKey2").status()); + + // share the skill item itself, not its containing folder + Response share = operationRequest("/v1/ops/resource/share/create", """ + { + "invitationType": "link", + "resources": [ + { "url": "skills/%s/shared-item/skill-a" } + ] + } + """.formatted(bucket)); + verify(share, 200); + InvitationLink invitationLink = ProxyUtil.convertToObject(share.body(), InvitationLink.class); + assertNotNull(invitationLink); + + verify(send(HttpMethod.GET, invitationLink.invitationLink(), "accept=true", null, "Api-key", "proxyKey2"), 200); + + // the item's own metadata is now visible to the invited user, correctly classified as an ITEM + Response metadata = listMetadata("shared-item/skill-a", "Api-key", "proxyKey2"); + verify(metadata, 200); + assertEquals("ITEM", nodeType(metadata)); + } + @Test void testCleanUpShareAccessWhenOnResourceDeletion() { Map files = Map.of("SKILL.md", VALID_MANIFEST.getBytes(StandardCharsets.UTF_8));