Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
Expand All @@ -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.
Expand Down Expand Up @@ -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<ResourceDescriptor> resourcesToCheck = itemResource.equals(folderResource)
? Set.of(itemResource)
: Set.of(itemResource, folderResource);

return proxy.getTaskExecutor()
.submit(() -> proxy.getAccessService().lookupPermissions(resourcesToCheck, context))
.compose(permissions -> {
Set<ResourceAccessType> itemPermissions = permissions.getOrDefault(finalItemResource, Set.of());
Set<ResourceAccessType> 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();
}
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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("/")) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -631,6 +631,35 @@ void testRevokeSharedAccess() {
assertEquals(403, getSkillFile("/revoke-skill", "SKILL.md", "Api-key", "proxyKey2").status());
}

@Test
void testItemShareInheritsMetadataAccess() {
Map<String, byte[]> 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<String, byte[]> files = Map.of("SKILL.md", VALID_MANIFEST.getBytes(StandardCharsets.UTF_8));
Expand Down