diff --git a/src/main/java/com/epam/aidial/core/AiDial.java b/src/main/java/com/epam/aidial/core/AiDial.java index 560a17324..1bb23d606 100644 --- a/src/main/java/com/epam/aidial/core/AiDial.java +++ b/src/main/java/com/epam/aidial/core/AiDial.java @@ -113,7 +113,7 @@ void start() throws Exception { LockService lockService = new LockService(redis, storage.getPrefix()); resourceService = new ResourceService(vertx, redis, storage, lockService, settings("resources"), storage.getPrefix()); InvitationService invitationService = new InvitationService(resourceService, encryptionService, settings("invitations")); - ShareService shareService = new ShareService(resourceService, invitationService, encryptionService); + ShareService shareService = new ShareService(resourceService, invitationService, encryptionService, storage); PublicationService publicationService = new PublicationService(encryptionService, resourceService, storage, generator, clock); ResourceOperationService resourceOperationService = new ResourceOperationService(resourceService, storage, invitationService, shareService); diff --git a/src/main/java/com/epam/aidial/core/controller/ControllerSelector.java b/src/main/java/com/epam/aidial/core/controller/ControllerSelector.java index 74cba54c6..ebff4ad27 100644 --- a/src/main/java/com/epam/aidial/core/controller/ControllerSelector.java +++ b/src/main/java/com/epam/aidial/core/controller/ControllerSelector.java @@ -45,10 +45,11 @@ public class ControllerSelector { private static final Pattern PATTERN_TOKENIZE = Pattern.compile("^/+v1/deployments/([^/]+)/tokenize$"); private static final Pattern PATTERN_TRUNCATE_PROMPT = Pattern.compile("^/+v1/deployments/([^/]+)/truncate_prompt$"); - private static final Pattern SHARE_RESOURCE_OPERATIONS = Pattern.compile("^/v1/ops/resource/share/(create|list|discard|revoke)$"); + private static final Pattern SHARE_RESOURCE_OPERATIONS = Pattern.compile("^/v1/ops/resource/share/(create|list|discard|revoke|copy)$"); private static final Pattern INVITATIONS = Pattern.compile("^/v1/invitations$"); private static final Pattern INVITATION = Pattern.compile("^/v1/invitations/([a-zA-Z0-9]+)$"); private static final Pattern PUBLICATIONS = Pattern.compile("^/v1/ops/publications/(list|get|create|delete|approve|reject)$"); + private static final Pattern PUBLICATION_RULES = Pattern.compile("^/v1/ops/publications/rules/list$"); private static final Pattern RESOURCE_OPERATIONS = Pattern.compile("^/v1/ops/resources/(move)$"); @@ -261,11 +262,17 @@ private static Controller selectPost(Proxy proxy, ProxyContext context, String p case "create" -> controller::createPublication; case "delete" -> controller::deletePublication; case "approve" -> controller::approvePublication; - case "reject" -> controller:: rejectPublication; + case "reject" -> controller::rejectPublication; default -> null; }; } + match = match(PUBLICATION_RULES, path); + if (match != null) { + PublicationController controller = new PublicationController(proxy, context); + return controller::listRules; + } + match = match(RESOURCE_OPERATIONS, path); if (match != null) { ResourceOperationController controller = new ResourceOperationController(proxy, context); diff --git a/src/main/java/com/epam/aidial/core/controller/PublicationController.java b/src/main/java/com/epam/aidial/core/controller/PublicationController.java index 7907e19b2..73a7c5550 100644 --- a/src/main/java/com/epam/aidial/core/controller/PublicationController.java +++ b/src/main/java/com/epam/aidial/core/controller/PublicationController.java @@ -6,6 +6,7 @@ import com.epam.aidial.core.data.Publications; import com.epam.aidial.core.data.ResourceLink; import com.epam.aidial.core.data.ResourceType; +import com.epam.aidial.core.data.Rules; import com.epam.aidial.core.security.AccessService; import com.epam.aidial.core.security.EncryptionService; import com.epam.aidial.core.service.LockService; @@ -133,6 +134,21 @@ public Future rejectPublication() { return Future.succeededFuture(); } + public Future listRules() { + context.getRequest() + .body() + .compose(body -> { + String url = ProxyUtil.convertToObject(body, ResourceLink.class).url(); + ResourceDescription rule = decodeRule(url); + checkRuleAccess(rule); + return vertx.executeBlocking(() -> publicationService.listRules(rule)); + }) + .onSuccess(rules -> context.respond(HttpStatus.OK, new Rules(rules))) + .onFailure(error -> respondError("Can't list rules", error)); + + return Future.succeededFuture(); + } + private void respondError(String message, Throwable error) { HttpStatus status = HttpStatus.INTERNAL_SERVER_ERROR; String body = null; @@ -172,6 +188,25 @@ private ResourceDescription decodePublication(String path, boolean allowPublic) return resource; } + private ResourceDescription decodeRule(String path) { + try { + if (!path.startsWith(BlobStorageUtil.PUBLIC_LOCATION)) { + throw new IllegalArgumentException(); + } + + String folder = path.substring(BlobStorageUtil.PUBLIC_LOCATION.length()); + ResourceDescription resource = ResourceDescription.fromEncoded(ResourceType.RULES, BlobStorageUtil.PUBLIC_BUCKET, BlobStorageUtil.PUBLIC_LOCATION, folder); + + if (!resource.isFolder()) { + throw new IllegalArgumentException(); + } + + return resource; + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Invalid resource: " + path, e); + } + } + private void checkAccess(ResourceDescription resource, boolean allowUser) { boolean hasAccess = accessService.hasAdminAccess(context); @@ -184,4 +219,10 @@ private void checkAccess(ResourceDescription resource, boolean allowUser) { throw new HttpException(HttpStatus.FORBIDDEN, "Forbidden resource: " + resource.getUrl()); } } + + private void checkRuleAccess(ResourceDescription rule) { + if (!accessService.hasReadAccess(rule, context)) { + throw new HttpException(HttpStatus.FORBIDDEN, "Forbidden resource: " + rule.getUrl()); + } + } } \ No newline at end of file diff --git a/src/main/java/com/epam/aidial/core/controller/ShareController.java b/src/main/java/com/epam/aidial/core/controller/ShareController.java index c9f401e37..afaf6c08d 100644 --- a/src/main/java/com/epam/aidial/core/controller/ShareController.java +++ b/src/main/java/com/epam/aidial/core/controller/ShareController.java @@ -2,17 +2,22 @@ import com.epam.aidial.core.Proxy; import com.epam.aidial.core.ProxyContext; +import com.epam.aidial.core.data.CopySharedAccessRequest; import com.epam.aidial.core.data.ListSharedResourcesRequest; import com.epam.aidial.core.data.ResourceLinkCollection; import com.epam.aidial.core.data.ShareResourcesRequest; import com.epam.aidial.core.security.EncryptionService; import com.epam.aidial.core.service.InvitationService; import com.epam.aidial.core.service.LockService; +import com.epam.aidial.core.service.ResourceService; import com.epam.aidial.core.service.ShareService; +import com.epam.aidial.core.storage.BlobStorage; import com.epam.aidial.core.storage.BlobStorageUtil; +import com.epam.aidial.core.storage.ResourceDescription; import com.epam.aidial.core.util.HttpException; import com.epam.aidial.core.util.HttpStatus; import com.epam.aidial.core.util.ProxyUtil; +import com.epam.aidial.core.util.ResourceUtil; import io.vertx.core.Future; import io.vertx.core.buffer.Buffer; import lombok.extern.slf4j.Slf4j; @@ -30,6 +35,8 @@ public class ShareController { private final EncryptionService encryptionService; private final LockService lockService; private final InvitationService invitationService; + private final ResourceService resourceService; + private final BlobStorage storage; public ShareController(Proxy proxy, ProxyContext context) { this.proxy = proxy; @@ -38,6 +45,8 @@ public ShareController(Proxy proxy, ProxyContext context) { this.encryptionService = proxy.getEncryptionService(); this.lockService = proxy.getLockService(); this.invitationService = proxy.getInvitationService(); + this.resourceService = proxy.getResourceService(); + this.storage = proxy.getStorage(); } public Future handle(Operation operation) { @@ -46,6 +55,7 @@ public Future handle(Operation operation) { case CREATE -> createSharedResources(); case REVOKE -> revokeSharedResources(); case DISCARD -> discardSharedResources(); + case COPY -> copySharedAccess(); default -> context.respond(HttpStatus.INTERNAL_SERVER_ERROR, "Operation %s is not supported".formatted(operation)); } @@ -137,6 +147,49 @@ public Future revokeSharedResources() { .onFailure(this::handleServiceError); } + public Future copySharedAccess() { + return context.getRequest() + .body() + .compose(buffer -> { + CopySharedAccessRequest request; + try { + request = ProxyUtil.convertToObject(buffer, CopySharedAccessRequest.class); + } catch (Exception e) { + log.error("Invalid request body provided", e); + throw new IllegalArgumentException("Can't initiate copy shared access request. Incorrect body provided"); + } + + String sourceUrl = request.sourceUrl(); + if (sourceUrl == null) { + throw new IllegalArgumentException("sourceUrl must be provided"); + } + String destinationUrl = request.destinationUrl(); + if (destinationUrl == null) { + throw new IllegalArgumentException("destinationUrl must be provided"); + } + + String bucketLocation = BlobStorageUtil.buildInitiatorBucket(context); + String bucket = encryptionService.encrypt(bucketLocation); + + ResourceDescription source = ResourceDescription.fromPrivateUrl(sourceUrl, encryptionService); + if (!bucket.equals(source.getBucketName())) { + throw new IllegalArgumentException("sourceUrl does not belong to the user"); + } + ResourceDescription destination = ResourceDescription.fromPrivateUrl(destinationUrl, encryptionService); + if (!bucket.equals(destination.getBucketName())) { + throw new IllegalArgumentException("destinationUrl does not belong to the user"); + } + + return proxy.getVertx().executeBlocking(() -> + lockService.underBucketLock(bucketLocation, () -> { + shareService.copySharedAccess(bucket, bucketLocation, source, destination); + return null; + })); + }) + .onSuccess(ignore -> context.respond(HttpStatus.OK)) + .onFailure(this::handleServiceError); + } + private void handleServiceError(Throwable error) { if (error instanceof IllegalArgumentException) { context.respond(HttpStatus.BAD_REQUEST, error.getMessage()); @@ -157,7 +210,11 @@ private ResourceLinkCollection getResourceLinkCollection(Buffer buffer, Operatio } } + private boolean hasResource(ResourceDescription resource) { + return ResourceUtil.hasResource(resource, resourceService, storage); + } + public enum Operation { - CREATE, LIST, DISCARD, REVOKE + CREATE, LIST, DISCARD, REVOKE, COPY } } diff --git a/src/main/java/com/epam/aidial/core/data/CopySharedAccessRequest.java b/src/main/java/com/epam/aidial/core/data/CopySharedAccessRequest.java new file mode 100644 index 000000000..9084032e2 --- /dev/null +++ b/src/main/java/com/epam/aidial/core/data/CopySharedAccessRequest.java @@ -0,0 +1,4 @@ +package com.epam.aidial.core.data; + +public record CopySharedAccessRequest(String sourceUrl, String destinationUrl) { +} diff --git a/src/main/java/com/epam/aidial/core/data/Rules.java b/src/main/java/com/epam/aidial/core/data/Rules.java new file mode 100644 index 000000000..2ff6a9698 --- /dev/null +++ b/src/main/java/com/epam/aidial/core/data/Rules.java @@ -0,0 +1,7 @@ +package com.epam.aidial.core.data; + +import java.util.List; +import java.util.Map; + +public record Rules(Map> rules) { +} \ No newline at end of file diff --git a/src/main/java/com/epam/aidial/core/service/PublicationService.java b/src/main/java/com/epam/aidial/core/service/PublicationService.java index 438a189da..2c7a8e628 100644 --- a/src/main/java/com/epam/aidial/core/service/PublicationService.java +++ b/src/main/java/com/epam/aidial/core/service/PublicationService.java @@ -4,6 +4,7 @@ import com.epam.aidial.core.data.MetadataBase; import com.epam.aidial.core.data.Publication; import com.epam.aidial.core.data.ResourceFolderMetadata; +import com.epam.aidial.core.data.ResourceItemMetadata; import com.epam.aidial.core.data.ResourceType; import com.epam.aidial.core.data.ResourceUrl; import com.epam.aidial.core.data.Rule; @@ -17,6 +18,7 @@ import com.fasterxml.jackson.core.type.TypeReference; import lombok.RequiredArgsConstructor; import org.apache.commons.lang3.mutable.MutableObject; +import org.apache.commons.lang3.tuple.Pair; import java.util.Collection; import java.util.HashMap; @@ -25,6 +27,8 @@ import java.util.List; import java.util.Map; import java.util.Set; +import java.util.TreeMap; +import java.util.concurrent.atomic.AtomicReference; import java.util.function.LongSupplier; import java.util.function.Supplier; import javax.annotation.Nullable; @@ -53,6 +57,10 @@ public class PublicationService { private static final Set ALLOWED_RESOURCES = Set.of(ResourceType.FILE, ResourceType.CONVERSATION, ResourceType.PROMPT); + /** + * Key is updated time from the metadata. Value is decoded map (folder path, list of rules). + */ + private final AtomicReference>>> cachedRules = new AtomicReference<>(); private final EncryptionService encryption; private final ResourceService resources; private final BlobStorage files; @@ -78,7 +86,7 @@ public boolean hasPublicAccess(ProxyContext context, ResourceDescription resourc return false; } - Map> rules = decodeRules(resources.getResource(PUBLIC_RULES)); + Map> rules = getCachedRules(); Map cache = new HashMap<>(); return evaluate(context, resource, rules, cache); @@ -89,7 +97,7 @@ public void filterForbidden(ProxyContext context, ResourceDescription folder, Re return; } - Map> rules = decodeRules(resources.getResource(PUBLIC_RULES)); + Map> rules = getCachedRules(); Map cache = new HashMap<>(); cache.put(ruleUrl(folder), true); @@ -101,6 +109,27 @@ public void filterForbidden(ProxyContext context, ResourceDescription folder, Re metadata.setItems(filtered); } + public Map> listRules(ResourceDescription resource) { + if (!resource.isFolder() || !resource.isPublic()) { + throw new IllegalArgumentException("Bad rule url: " + resource.getUrl()); + } + + Map> rules = getCachedRules(); + Map> result = new TreeMap<>(); + + while (resource != null) { + String url = ruleUrl(resource);; + List list = rules.get(url); + resource = resource.getParent(); + + if (list != null) { + result.put(url, list); + } + } + + return result; + } + public Collection listPublications(ResourceDescription resource) { if (resource.getType() != ResourceType.PUBLICATION || !resource.isRootFolder()) { throw new IllegalArgumentException("Bad publication url: " + resource.getUrl()); @@ -473,6 +502,24 @@ private String encodeReviewBucket(ResourceDescription bucket, String id) { return encryption.encrypt(path); } + private Map> getCachedRules() { + ResourceItemMetadata meta = resources.getResourceMetadata(PUBLIC_RULES); + long key = (meta == null) ? Long.MIN_VALUE : meta.getUpdatedAt(); + Pair>> current = cachedRules.get(); + + if (current == null || current.getKey() != key) { + Pair resource = resources.getResourceWithMetadata(PUBLIC_RULES); + Pair>> next = (resource == null) + ? Pair.of(Long.MIN_VALUE, decodeRules(null)) + : Pair.of(resource.getKey().getUpdatedAt(), decodeRules(resource.getValue())); + + cachedRules.compareAndSet(current, next); + current = next; + } + + return current.getValue(); + } + private static boolean evaluate(ProxyContext context, ResourceDescription resource, Map> rules, @@ -542,17 +589,7 @@ private static String encodePublications(Map publications) private static Map> decodeRules(String json) { Map> rules = ProxyUtil.convertToObject(json, RULES_TYPE); - - if (rules == null) { - Rule rule = new Rule(); - rule.setSource("roles"); - rule.setFunction(Rule.Function.TRUE); - rule.setTargets(List.of()); - rules = new LinkedHashMap<>(); - rules.put(PUBLIC_LOCATION, List.of(rule)); - } - - return rules; + return (rules == null) ? new LinkedHashMap<>() : rules; } private static String encodeRules(Map> rules) { diff --git a/src/main/java/com/epam/aidial/core/service/ResourceOperationService.java b/src/main/java/com/epam/aidial/core/service/ResourceOperationService.java index 4f0c16b7a..48ab9a284 100644 --- a/src/main/java/com/epam/aidial/core/service/ResourceOperationService.java +++ b/src/main/java/com/epam/aidial/core/service/ResourceOperationService.java @@ -3,6 +3,7 @@ import com.epam.aidial.core.data.ResourceType; import com.epam.aidial.core.storage.BlobStorage; import com.epam.aidial.core.storage.ResourceDescription; +import com.epam.aidial.core.util.ResourceUtil; import lombok.AllArgsConstructor; @AllArgsConstructor @@ -35,7 +36,6 @@ public void moveResource(String bucket, String location, ResourceDescription sou .formatted(sourceResourceUrl, destinationResourceUrl)); } storage.copy(sourceResourcePath, destinationResourcePath); - storage.delete(sourceResourcePath); } case CONVERSATION, PROMPT -> { boolean copied = resourceService.copyResource(source, destination, overwriteIfExists); @@ -43,7 +43,6 @@ public void moveResource(String bucket, String location, ResourceDescription sou throw new IllegalArgumentException("Can't move resource %s to %s, because destination resource already exists" .formatted(sourceResourceUrl, destinationResourceUrl)); } - resourceService.deleteResource(source); } default -> throw new IllegalArgumentException("Unsupported resource type " + resourceType); } @@ -51,14 +50,20 @@ public void moveResource(String bucket, String location, ResourceDescription sou invitationService.moveResource(bucket, location, source, destination); // move shared access if any shareService.moveSharedAccess(bucket, location, source, destination); + + deleteResource(source); } private boolean hasResource(ResourceDescription resource) { - return switch (resource.getType()) { - case FILE -> storage.exists(resource.getAbsoluteFilePath()); - case CONVERSATION, PROMPT -> resourceService.hasResource(resource); + return ResourceUtil.hasResource(resource, resourceService, storage); + } + + private void deleteResource(ResourceDescription resource) { + switch (resource.getType()) { + case FILE -> storage.delete(resource.getAbsoluteFilePath()); + case CONVERSATION, PROMPT -> resourceService.deleteResource(resource); default -> throw new IllegalArgumentException("Unsupported resource type " + resource.getType()); - }; + } } } diff --git a/src/main/java/com/epam/aidial/core/service/ResourceService.java b/src/main/java/com/epam/aidial/core/service/ResourceService.java index c548e361c..3861b6c8b 100644 --- a/src/main/java/com/epam/aidial/core/service/ResourceService.java +++ b/src/main/java/com/epam/aidial/core/service/ResourceService.java @@ -12,6 +12,7 @@ import lombok.Getter; import lombok.SneakyThrows; import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.tuple.Pair; import org.jclouds.blobstore.domain.Blob; import org.jclouds.blobstore.domain.BlobMetadata; import org.jclouds.blobstore.domain.PageSet; @@ -114,7 +115,7 @@ public void close() { public MetadataBase getMetadata(ResourceDescription descriptor, String token, int limit, boolean recursive) { return descriptor.isFolder() ? getFolderMetadata(descriptor, token, limit, recursive) - : getItemMetadata(descriptor); + : getResourceMetadata(descriptor); } private ResourceFolderMetadata getFolderMetadata(ResourceDescription descriptor, String token, int limit, boolean recursive) { @@ -156,7 +157,11 @@ private ResourceFolderMetadata getFolderMetadata(ResourceDescription descriptor, return new ResourceFolderMetadata(descriptor, resources).setNextToken(set.getNextMarker()); } - private ResourceItemMetadata getItemMetadata(ResourceDescription descriptor) { + public ResourceItemMetadata getResourceMetadata(ResourceDescription descriptor) { + if (descriptor.isFolder()) { + throw new IllegalArgumentException("Resource folder: " + descriptor.getUrl()); + } + String redisKey = redisKey(descriptor); String blobKey = blobKey(descriptor); Result result = redisGet(redisKey, false); @@ -187,12 +192,12 @@ public boolean hasResource(ResourceDescription descriptor) { } @Nullable - public String getResource(ResourceDescription descriptor) { - return getResource(descriptor, true); + public Pair getResourceWithMetadata(ResourceDescription descriptor) { + return getResourceWithMetadata(descriptor, true); } @Nullable - public String getResource(ResourceDescription descriptor, boolean lock) { + public Pair getResourceWithMetadata(ResourceDescription descriptor, boolean lock) { String redisKey = redisKey(descriptor); Result result = redisGet(redisKey, true); @@ -208,7 +213,26 @@ public String getResource(ResourceDescription descriptor, boolean lock) { } } - return result.exists ? result.body : null; + if (result.exists) { + ResourceItemMetadata metadata = new ResourceItemMetadata(descriptor) + .setCreatedAt(result.createdAt) + .setUpdatedAt(result.updatedAt); + + return Pair.of(metadata, result.body); + } + + return null; + } + + @Nullable + public String getResource(ResourceDescription descriptor) { + return getResource(descriptor, true); + } + + @Nullable + public String getResource(ResourceDescription descriptor, boolean lock) { + Pair result = getResourceWithMetadata(descriptor, lock); + return (result == null) ? null : result.getRight(); } public ResourceItemMetadata putResource(ResourceDescription descriptor, String body, boolean overwrite) { diff --git a/src/main/java/com/epam/aidial/core/service/ShareService.java b/src/main/java/com/epam/aidial/core/service/ShareService.java index ea32a3ad6..91a9f0253 100644 --- a/src/main/java/com/epam/aidial/core/service/ShareService.java +++ b/src/main/java/com/epam/aidial/core/service/ShareService.java @@ -13,9 +13,11 @@ import com.epam.aidial.core.data.SharedByMeDto; import com.epam.aidial.core.data.SharedResourcesResponse; import com.epam.aidial.core.security.EncryptionService; +import com.epam.aidial.core.storage.BlobStorage; import com.epam.aidial.core.storage.BlobStorageUtil; import com.epam.aidial.core.storage.ResourceDescription; import com.epam.aidial.core.util.ProxyUtil; +import com.epam.aidial.core.util.ResourceUtil; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -36,6 +38,7 @@ public class ShareService { private final ResourceService resourceService; private final InvitationService invitationService; private final EncryptionService encryptionService; + private final BlobStorage storage; /** * Returns a list of resources shared with user. @@ -304,6 +307,14 @@ public void discardSharedAccess(String bucket, String location, ResourceLinkColl } public void copySharedAccess(String bucket, String location, ResourceDescription source, ResourceDescription destination) { + if (!hasResource(source)) { + throw new IllegalArgumentException("source resource %s does not exists".formatted(source.getUrl())); + } + + if (!hasResource(destination)) { + throw new IllegalArgumentException("destination resource %s dos not exists".formatted(destination.getUrl())); + } + ResourceType sourceResourceType = source.getType(); ResourceDescription sharedByMeResource = getShareResource(ResourceType.SHARED_BY_ME, sourceResourceType, bucket, location); SharedByMeDto sharedByMeDto = ProxyUtil.convertToObject(resourceService.getResource(sharedByMeResource), SharedByMeDto.class); @@ -361,10 +372,12 @@ private void addSharedResource(String bucket, String location, String link, Reso ResourceDescription sharedByMeResource = getShareResource(ResourceType.SHARED_WITH_ME, resourceType, bucket, location); resourceService.computeResource(sharedByMeResource, state -> { ResourceLinkCollection sharedWithMe = ProxyUtil.convertToObject(state, ResourceLinkCollection.class); - if (sharedWithMe != null) { - sharedWithMe.getResources().add(new ResourceLink(link)); + if (sharedWithMe == null) { + sharedWithMe = new ResourceLinkCollection(new HashSet<>()); } + sharedWithMe.getResources().add(new ResourceLink(link)); + return ProxyUtil.convertToString(sharedWithMe); }); } @@ -389,6 +402,10 @@ private ResourceDescription getResourceFromLink(String url) { } } + private boolean hasResource(ResourceDescription resource) { + return ResourceUtil.hasResource(resource, resourceService, storage); + } + private ResourceDescription getShareResource(ResourceType shareResourceType, ResourceType requestedResourceType, String bucket, String location) { return ResourceDescription.fromDecoded(shareResourceType, bucket, location, requestedResourceType.getGroup() + BlobStorageUtil.PATH_SEPARATOR + SHARE_RESOURCE_FILENAME); diff --git a/src/main/java/com/epam/aidial/core/util/ResourceUtil.java b/src/main/java/com/epam/aidial/core/util/ResourceUtil.java new file mode 100644 index 000000000..0e7cf523e --- /dev/null +++ b/src/main/java/com/epam/aidial/core/util/ResourceUtil.java @@ -0,0 +1,18 @@ +package com.epam.aidial.core.util; + +import com.epam.aidial.core.service.ResourceService; +import com.epam.aidial.core.storage.BlobStorage; +import com.epam.aidial.core.storage.ResourceDescription; +import lombok.experimental.UtilityClass; + +@UtilityClass +public class ResourceUtil { + + public static boolean hasResource(ResourceDescription resource, ResourceService resourceService, BlobStorage storage) { + return switch (resource.getType()) { + case FILE -> storage.exists(resource.getAbsoluteFilePath()); + case CONVERSATION, PROMPT -> resourceService.hasResource(resource); + default -> throw new IllegalArgumentException("Unsupported resource type " + resource.getType()); + }; + } +} diff --git a/src/test/java/com/epam/aidial/core/PublicationApiTest.java b/src/test/java/com/epam/aidial/core/PublicationApiTest.java index b6692c545..0ecc26e8c 100644 --- a/src/test/java/com/epam/aidial/core/PublicationApiTest.java +++ b/src/test/java/com/epam/aidial/core/PublicationApiTest.java @@ -518,4 +518,89 @@ void testPublicationList() { } """); } + + @Test + void listRules() { + Response response = operationRequest("/v1/ops/publications/rules/list", """ + {"url": ""} + """); + verify(response, 400); + + response = operationRequest("/v1/ops/publications/rules/list", """ + {"url": "public"} + """); + verify(response, 400); + + response = operationRequest("/v1/ops/publications/rules/list", """ + {"url": "public/"} + """); + verifyJson(response, 200, """ + { + "rules" : { } + } + """); + + response = operationRequest("/v1/ops/publications/rules/list", """ + {"url": "public/"} + """, "authorization", "user"); + verifyJson(response, 200, """ + { + "rules" : { } + } + """); + + response = operationRequest("/v1/ops/publications/rules/list", """ + {"url": "public/"} + """, "authorization", "admin"); + verifyJson(response, 200, """ + { + "rules" : { } + } + """); + + response = resourceRequest(HttpMethod.PUT, "/my/folder/conversation", CONVERSATION_BODY_1); + verify(response, 200); + + response = operationRequest("/v1/ops/publications/create", PUBLICATION_REQUEST.formatted(bucket, bucket)); + verify(response, 200); + + response = operationRequest("/v1/ops/publications/approve", PUBLICATION_URL, "authorization", "admin"); + verify(response, 200); + + + response = operationRequest("/v1/ops/publications/rules/list", """ + {"url": "public/folder/"} + """, "authorization", "user"); + verifyJson(response, 200, """ + { + "rules" : { + "public/folder/" : [ { + "function" : "EQUAL", + "source" : "roles", + "targets" : [ "user" ] + } ] + } + } + """); + + response = operationRequest("/v1/ops/publications/rules/list", """ + {"url": "public/folder/"} + """, "authorization", "admin"); + verifyJson(response, 200, """ + { + "rules" : { + "public/folder/" : [ { + "function" : "EQUAL", + "source" : "roles", + "targets" : [ "user" ] + } ] + } + } + """); + + response = operationRequest("/v1/ops/publications/rules/list", """ + {"url": "public/folder/"} + """); + verify(response, 403); + } } \ No newline at end of file diff --git a/src/test/java/com/epam/aidial/core/ResourceBaseTest.java b/src/test/java/com/epam/aidial/core/ResourceBaseTest.java index a8f952d5b..fca783ff6 100644 --- a/src/test/java/com/epam/aidial/core/ResourceBaseTest.java +++ b/src/test/java/com/epam/aidial/core/ResourceBaseTest.java @@ -67,6 +67,15 @@ public class ResourceBaseTest { } """; + public static final String PROMPT_BODY = """ + { + "id": "prompt_id", + "name": "prompt", + "folderId": "folder", + "content": "content" + } + """; + RedisServer redis; AiDial dial; Path testDir; diff --git a/src/test/java/com/epam/aidial/core/ShareApiTest.java b/src/test/java/com/epam/aidial/core/ShareApiTest.java index e30719543..2c7a8ad50 100644 --- a/src/test/java/com/epam/aidial/core/ShareApiTest.java +++ b/src/test/java/com/epam/aidial/core/ShareApiTest.java @@ -680,4 +680,321 @@ public void testAcceptOwnShareRequest() { response = send(HttpMethod.GET, invitationLink.invitationLink(), "accept=true", null); verify(response, 400, "Resource conversations/3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST/folder/conversation already belong to you"); } + + @Test + public void testCopySharedAccess() { + // create conversation + Response response = resourceRequest(HttpMethod.PUT, "/folder/conversation", CONVERSATION_BODY_1); + verifyNotExact(response, 200, "\"url\":\"conversations/3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST/folder/conversation\""); + + // initialize share request + response = operationRequest("/v1/ops/resource/share/create", """ + { + "invitationType": "link", + "resources": [ + { + "url": "conversations/3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST/folder/conversation" + } + ] + } + """); + verify(response, 200); + InvitationLink invitationLink = ProxyUtil.convertToObject(response.body(), InvitationLink.class); + assertNotNull(invitationLink); + + // verify invitation details + response = send(HttpMethod.GET, invitationLink.invitationLink(), null, null); + verifyNotExact(response, 200, "\"url\":\"conversations/3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST/folder/conversation\""); + + // verify user2 do not have access to the conversation + response = resourceRequest(HttpMethod.GET, "/folder/conversation", null, "Api-key", "proxyKey2"); + verify(response, 403); + + // accept invitation + response = send(HttpMethod.GET, invitationLink.invitationLink(), "accept=true", null, "Api-key", "proxyKey2"); + verify(response, 200); + + // verify user2 has access to the conversation + response = resourceRequest(HttpMethod.GET, "/folder/conversation", null, "Api-key", "proxyKey2"); + verify(response, 200, CONVERSATION_BODY_1); + + // create conversation2 + response = resourceRequest(HttpMethod.PUT, "/folder/conversation2", CONVERSATION_BODY_2); + verifyNotExact(response, 200, "\"url\":\"conversations/3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST/folder/conversation2\""); + + // copy shared access + response = operationRequest("/v1/ops/resource/share/copy", """ + { + "sourceUrl": "conversations/3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST/folder/conversation", + "destinationUrl": "conversations/3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST/folder/conversation2" + } + """); + verify(response, 200); + + // verify user2 has access to the conversation2 + response = resourceRequest(HttpMethod.GET, "/folder/conversation2", null, "Api-key", "proxyKey2"); + verify(response, 200, CONVERSATION_BODY_2); + + // verify user1 has no shared_with_me resources + response = operationRequest("/v1/ops/resource/share/list", """ + { + "resourceTypes": ["CONVERSATION"], + "with": "me" + } + """); + verifyJson(response, 200, """ + { + "resources": [] + } + """); + + // verify user2 has shared_with_me resource + response = operationRequest("/v1/ops/resource/share/list", """ + { + "resourceTypes": ["CONVERSATION"], + "with": "me" + } + """, "Api-key", "proxyKey2"); + + verifyJsonNotExact(response, 200, """ + { + "resources" : [ { + "name" : "conversation2", + "parentPath" : "folder", + "bucket" : "3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST", + "url" : "conversations/3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST/folder/conversation2", + "nodeType" : "ITEM", + "resourceType" : "CONVERSATION" + }, + { + "name" : "conversation", + "parentPath" : "folder", + "bucket" : "3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST", + "url" : "conversations/3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST/folder/conversation", + "nodeType" : "ITEM", + "resourceType" : "CONVERSATION" + } + ] + } + """); + + // verify user1 has shared_by_me resource + response = operationRequest("/v1/ops/resource/share/list", """ + { + "resourceTypes": ["CONVERSATION"], + "with": "others" + } + """); + verifyJsonNotExact(response, 200, """ + { + "resources" : [ { + "name" : "conversation2", + "parentPath" : "folder", + "bucket" : "3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST", + "url" : "conversations/3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST/folder/conversation2", + "nodeType" : "ITEM", + "resourceType" : "CONVERSATION" + }, + { + "name" : "conversation", + "parentPath" : "folder", + "bucket" : "3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST", + "url" : "conversations/3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST/folder/conversation", + "nodeType" : "ITEM", + "resourceType" : "CONVERSATION" + } + ] + } + """); + + // verify user2 has no shared_by_me resources + response = operationRequest("/v1/ops/resource/share/list", """ + { + "resourceTypes": ["CONVERSATION"], + "with": "others" + } + """, "Api-key", "proxyKey2"); + verifyJson(response, 200, """ + { + "resources": [] + } + """); + } + + @Test + public void testCopySharedAccessWithDifferentResourceTypes() { + // create conversation + Response response = resourceRequest(HttpMethod.PUT, "/folder/conversation", CONVERSATION_BODY_1); + verifyNotExact(response, 200, "\"url\":\"conversations/3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST/folder/conversation\""); + + // initialize share request + response = operationRequest("/v1/ops/resource/share/create", """ + { + "invitationType": "link", + "resources": [ + { + "url": "conversations/3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST/folder/conversation" + } + ] + } + """); + verify(response, 200); + InvitationLink invitationLink = ProxyUtil.convertToObject(response.body(), InvitationLink.class); + assertNotNull(invitationLink); + + // verify invitation details + response = send(HttpMethod.GET, invitationLink.invitationLink(), null, null); + verifyNotExact(response, 200, "\"url\":\"conversations/3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST/folder/conversation\""); + + // verify user2 do not have access to the conversation + response = resourceRequest(HttpMethod.GET, "/folder/conversation", null, "Api-key", "proxyKey2"); + verify(response, 403); + + // accept invitation + response = send(HttpMethod.GET, invitationLink.invitationLink(), "accept=true", null, "Api-key", "proxyKey2"); + verify(response, 200); + + // verify user2 has access to the conversation + response = resourceRequest(HttpMethod.GET, "/folder/conversation", null, "Api-key", "proxyKey2"); + verify(response, 200, CONVERSATION_BODY_1); + + // create prompt + response = send(HttpMethod.PUT, "/v1/prompts/3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST/folder/prompt", null, PROMPT_BODY); + verifyNotExact(response, 200, "\"url\":\"prompts/3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST/folder/prompt\""); + + // copy shared access + response = operationRequest("/v1/ops/resource/share/copy", """ + { + "sourceUrl": "conversations/3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST/folder/conversation", + "destinationUrl": "prompts/3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST/folder/prompt" + } + """); + verify(response, 200); + + // verify user2 has access to the conversation2 + response = send(HttpMethod.GET, "/v1/prompts/3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST/folder/prompt", null, null, "Api-key", "proxyKey2"); + verify(response, 200, PROMPT_BODY); + + // verify user1 has no shared_with_me resources + response = operationRequest("/v1/ops/resource/share/list", """ + { + "resourceTypes": ["CONVERSATION", "PROMPT"], + "with": "me" + } + """); + verifyJson(response, 200, """ + { + "resources": [] + } + """); + + // verify user2 has shared_with_me resource + response = operationRequest("/v1/ops/resource/share/list", """ + { + "resourceTypes": ["CONVERSATION", "PROMPT"], + "with": "me" + } + """, "Api-key", "proxyKey2"); + + verifyJsonNotExact(response, 200, """ + { + "resources" : [ { + "name" : "prompt", + "parentPath" : "folder", + "bucket" : "3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST", + "url" : "prompts/3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST/folder/prompt", + "nodeType" : "ITEM", + "resourceType" : "PROMPT" + }, + { + "name" : "conversation", + "parentPath" : "folder", + "bucket" : "3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST", + "url" : "conversations/3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST/folder/conversation", + "nodeType" : "ITEM", + "resourceType" : "CONVERSATION" + } + ] + } + """); + + // verify user1 has shared_by_me resource + response = operationRequest("/v1/ops/resource/share/list", """ + { + "resourceTypes": ["CONVERSATION", "PROMPT"], + "with": "others" + } + """); + verifyJsonNotExact(response, 200, """ + { + "resources" : [ { + "name" : "prompt", + "parentPath" : "folder", + "bucket" : "3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST", + "url" : "prompts/3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST/folder/prompt", + "nodeType" : "ITEM", + "resourceType" : "PROMPT" + }, + { + "name" : "conversation", + "parentPath" : "folder", + "bucket" : "3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST", + "url" : "conversations/3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST/folder/conversation", + "nodeType" : "ITEM", + "resourceType" : "CONVERSATION" + } + ] + } + """); + + // verify user2 has no shared_by_me resources + response = operationRequest("/v1/ops/resource/share/list", """ + { + "resourceTypes": ["CONVERSATION", "PROMPT"], + "with": "others" + } + """, "Api-key", "proxyKey2"); + verifyJson(response, 200, """ + { + "resources": [] + } + """); + } + + @Test + public void testInvalidSharedAccessCopy() { + // verify sourUrl must be present + Response response = operationRequest("/v1/ops/resource/share/copy", """ + { + "destinationUrl": "conversations/3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST/folder/conversation2" + } + """); + verify(response, 400); + + // verify destination url must be present + response = operationRequest("/v1/ops/resource/share/copy", """ + { + "sourceUrl": "conversations/3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST/folder/conversation" + } + """); + verify(response, 400); + + // verify resources must belong to the user + response = operationRequest("/v1/ops/resource/share/copy", """ + { + "sourceUrl": "conversations/7G9WZNcoY26Vy9D7bEgbv6zqbJGfyDp9KZyEbJR4XMZt/folder/conversation", + "destinationUrl": "prompts/3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST/folder/prompt" + } + """); + verify(response, 400); + + // verify resource should exist + response = operationRequest("/v1/ops/resource/share/copy", """ + { + "sourceUrl": "conversations/3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST/folder/conversation", + "destinationUrl": "prompts/3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST/folder/prompt" + } + """); + verify(response, 400); + } }