diff --git a/README.md b/README.md index 1a03fa7a0..dbda4b98c 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,8 @@ Static settings are used on startup and cannot be changed while application is r | resources.cacheExpiration | 300000 | Expiration in milliseconds for synced resources in Redis | resources.compressionMinSize | 256 | Compress a resource with gzip if its size in bytes more or equal to this value | redis.singleServerConfig.address | - | Redis single server addresses, e.g. "redis://host:port" -| redis.clusterServersConfig.nodeAddresses | - | Json array with Redis cluster server addresses, e.g. ["redis://host1:port1","redis://host2:port2"] +| redis.clusterServersConfig.nodeAddresses | - | Json array with Redis cluster server addresses, e.g. ["redis://host1:port1","redis://host2:port2"] +| invitations.ttlInSeconds | 259200 | Invitation time to live in seconds ### Redis The Redis can be used as a cache with volatile-* eviction policies: diff --git a/src/main/java/com/epam/aidial/core/AiDial.java b/src/main/java/com/epam/aidial/core/AiDial.java index 5ab15c939..6e2b89141 100644 --- a/src/main/java/com/epam/aidial/core/AiDial.java +++ b/src/main/java/com/epam/aidial/core/AiDial.java @@ -10,8 +10,10 @@ import com.epam.aidial.core.security.AccessTokenValidator; import com.epam.aidial.core.security.ApiKeyStore; 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.token.TokenStatsTracker; import com.epam.aidial.core.upstream.UpstreamBalancer; @@ -98,11 +100,13 @@ void start() throws Exception { resourceService = new ResourceService(vertx, redis, storage, lockService, settings("resources")); } + InvitationService invitationService = new InvitationService(resourceService, encryptionService, settings("invitations")); + ShareService shareService = new ShareService(resourceService, invitationService, encryptionService); RateLimiter rateLimiter = new RateLimiter(vertx, resourceService); proxy = new Proxy(vertx, client, configStore, logStore, rateLimiter, upstreamBalancer, accessTokenValidator, - storage, encryptionService, apiKeyStore, tokenStatsTracker, resourceService); + storage, encryptionService, apiKeyStore, tokenStatsTracker, resourceService, invitationService, shareService); server = vertx.createHttpServer(new HttpServerOptions(settings("server"))).requestHandler(proxy); open(server, HttpServer::listen); diff --git a/src/main/java/com/epam/aidial/core/Proxy.java b/src/main/java/com/epam/aidial/core/Proxy.java index ab3741275..a6496db75 100644 --- a/src/main/java/com/epam/aidial/core/Proxy.java +++ b/src/main/java/com/epam/aidial/core/Proxy.java @@ -11,7 +11,9 @@ import com.epam.aidial.core.security.ApiKeyStore; import com.epam.aidial.core.security.EncryptionService; import com.epam.aidial.core.security.ExtractedClaims; +import com.epam.aidial.core.service.InvitationService; 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.token.TokenStatsTracker; import com.epam.aidial.core.upstream.UpstreamBalancer; @@ -70,6 +72,8 @@ public class Proxy implements Handler { private final ApiKeyStore apiKeyStore; private final TokenStatsTracker tokenStatsTracker; private final ResourceService resourceService; + private final InvitationService invitationService; + private final ShareService shareService; @Override public void handle(HttpServerRequest request) { diff --git a/src/main/java/com/epam/aidial/core/controller/AccessControlBaseController.java b/src/main/java/com/epam/aidial/core/controller/AccessControlBaseController.java index e50ad519d..16374608b 100644 --- a/src/main/java/com/epam/aidial/core/controller/AccessControlBaseController.java +++ b/src/main/java/com/epam/aidial/core/controller/AccessControlBaseController.java @@ -21,37 +21,64 @@ public abstract class AccessControlBaseController { /** * @param bucket url encoded bucket name - * @param path url encoded resource path + * @param path url encoded resource path */ public Future handle(String resourceType, String bucket, String path) { ResourceType type = ResourceType.of(resourceType); String urlDecodedBucket = UrlUtil.decodePath(bucket); String decryptedBucket = proxy.getEncryptionService().decrypt(urlDecodedBucket); - boolean hasReadAccess = isSharedWithMe(type, bucket, path); - boolean hasWriteAccess = hasWriteAccess(path, decryptedBucket); - boolean hasAccess = checkFullAccess ? hasWriteAccess : hasReadAccess || hasWriteAccess; - - if (!hasAccess) { - return context.respond(HttpStatus.FORBIDDEN, "You don't have an access to the bucket " + bucket); + if (decryptedBucket == null) { + context.respond(HttpStatus.FORBIDDEN, "You don't have an access to the %s %s/%s".formatted(type, bucket, path)); + return Future.succeededFuture(); } + // we should take a real user bucket not provided from resource + String actualUserLocation = BlobStorageUtil.buildInitiatorBucket(context); + String actualUserBucket = proxy.getEncryptionService().encrypt(actualUserLocation); + ResourceDescription resource; try { resource = ResourceDescription.fromEncoded(type, urlDecodedBucket, decryptedBucket, path); } catch (Exception ex) { String errorMessage = ex.getMessage() != null ? ex.getMessage() : DEFAULT_RESOURCE_ERROR_MESSAGE.formatted(path); - return context.respond(HttpStatus.BAD_REQUEST, errorMessage); + context.respond(HttpStatus.BAD_REQUEST, errorMessage); + return Future.succeededFuture(); } - handle(resource); - return Future.succeededFuture(); + return proxy.getVertx() + .executeBlocking(() -> { + boolean hasWriteAccess = hasWriteAccess(path, decryptedBucket); + if (hasWriteAccess) { + return true; + } + + if (!checkFullAccess) { + // some per-request API-keys may have access to the resources implicitly + boolean isAutoShared = context.getApiKeyData().getAttachedFiles().contains(resource.getUrl()); + if (isAutoShared) { + return true; + } + + return isSharedResource(resource, actualUserBucket, actualUserLocation); + } + + return false; + }) + .map(hasAccess -> { + if (hasAccess) { + handle(resource); + } else { + context.respond(HttpStatus.FORBIDDEN, "You don't have an access to the %s %s/%s".formatted(type, bucket, path)); + } + return null; + }); } protected abstract Future handle(ResourceDescription resource); - protected boolean isSharedWithMe(ResourceType type, String bucket, String filePath) { - String url = type.getGroup() + BlobStorageUtil.PATH_SEPARATOR + bucket + BlobStorageUtil.PATH_SEPARATOR + filePath; - return context.getApiKeyData().getAttachedFiles().contains(url); + protected boolean isSharedResource(ResourceDescription resource, String userBucket, String userLocation) { + // resource was shared explicitly by share API + return (proxy.getResourceService() != null && proxy.getShareService().hasReadAccess(userBucket, userLocation, resource)); } protected boolean hasWriteAccess(String filePath, String decryptedBucket) { 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 b5656b0cf..202f882a2 100644 --- a/src/main/java/com/epam/aidial/core/controller/ControllerSelector.java +++ b/src/main/java/com/epam/aidial/core/controller/ControllerSelector.java @@ -45,6 +45,10 @@ 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 INVITATIONS = Pattern.compile("^/v1/invitations$"); + private static final Pattern INVITATION = Pattern.compile("^/v1/invitations/([a-zA-Z0-9]+)$"); + public Controller select(Proxy proxy, ProxyContext context) { String path = context.getRequest().path(); HttpMethod method = context.getRequest().method(); @@ -171,6 +175,19 @@ private static Controller selectGet(Proxy proxy, ProxyContext context, String pa return controller::getBucket; } + match = match(INVITATION, path); + if (match != null) { + String invitationId = UrlUtil.decodePath(match.group(1)); + InvitationController controller = new InvitationController(proxy, context); + return () -> controller.getOrAcceptInvitation(invitationId); + } + + match = match(INVITATIONS, path); + if (match != null) { + InvitationController controller = new InvitationController(proxy, context); + return controller::getInvitations; + } + return null; } @@ -222,6 +239,15 @@ private static Controller selectPost(Proxy proxy, ProxyContext context, String p return () -> controller.handle(deploymentId, getter, true); } + match = match(SHARE_RESOURCE_OPERATIONS, path); + if (match != null) { + String operation = match.group(1); + ShareController.Operation op = ShareController.Operation.valueOf(operation.toUpperCase()); + + ShareController controller = new ShareController(proxy, context); + return () -> controller.handle(op); + } + return null; } @@ -243,6 +269,13 @@ private static Controller selectDelete(Proxy proxy, ProxyContext context, String return () -> controller.handle(resource, bucket, relativePath); } + match = match(INVITATION, path); + if (match != null) { + String invitationId = UrlUtil.decodePath(match.group(1)); + InvitationController controller = new InvitationController(proxy, context); + return () -> controller.deleteInvitation(invitationId); + } + return null; } diff --git a/src/main/java/com/epam/aidial/core/controller/InvitationController.java b/src/main/java/com/epam/aidial/core/controller/InvitationController.java new file mode 100644 index 000000000..de24d7c9c --- /dev/null +++ b/src/main/java/com/epam/aidial/core/controller/InvitationController.java @@ -0,0 +1,98 @@ +package com.epam.aidial.core.controller; + +import com.epam.aidial.core.Proxy; +import com.epam.aidial.core.ProxyContext; +import com.epam.aidial.core.security.EncryptionService; +import com.epam.aidial.core.service.InvitationService; +import com.epam.aidial.core.service.PermissionDeniedException; +import com.epam.aidial.core.service.ResourceNotFoundException; +import com.epam.aidial.core.service.ShareService; +import com.epam.aidial.core.storage.BlobStorageUtil; +import com.epam.aidial.core.util.HttpStatus; +import io.vertx.core.Future; + +public class InvitationController { + + private final Proxy proxy; + private final ProxyContext context; + private final InvitationService invitationService; + private final ShareService shareService; + private final EncryptionService encryptionService; + + public InvitationController(Proxy proxy, ProxyContext context) { + this.proxy = proxy; + this.context = context; + this.invitationService = proxy.getInvitationService(); + this.shareService = proxy.getShareService(); + this.encryptionService = proxy.getEncryptionService(); + } + + public Future getInvitations() { + proxy.getVertx() + .executeBlocking(() -> { + String bucketLocation = BlobStorageUtil.buildInitiatorBucket(context); + String bucket = encryptionService.encrypt(bucketLocation); + return invitationService.getMyInvitations(bucket, bucketLocation); + }) + .onSuccess(response -> context.respond(HttpStatus.OK, response)) + .onFailure(error -> context.respond(HttpStatus.INTERNAL_SERVER_ERROR, error.getMessage())); + return Future.succeededFuture(); + } + + public Future getOrAcceptInvitation(String invitationId) { + String accept = context.getRequest().getParam("accept"); + if (accept != null) { + proxy.getVertx() + .executeBlocking(() -> { + String bucketLocation = BlobStorageUtil.buildInitiatorBucket(context); + String bucket = encryptionService.encrypt(bucketLocation); + shareService.acceptSharedResources(bucket, bucketLocation, invitationId); + return null; + }) + .onSuccess(ignore -> context.respond(HttpStatus.OK)) + .onFailure(error -> { + if (error instanceof ResourceNotFoundException) { + context.respond(HttpStatus.NOT_FOUND, "No invitation found for ID " + invitationId); + } else if (error instanceof IllegalArgumentException) { + context.respond(HttpStatus.BAD_REQUEST, error.getMessage()); + } else { + context.respond(HttpStatus.INTERNAL_SERVER_ERROR, error.getMessage()); + } + }); + } else { + proxy.getVertx() + .executeBlocking(() -> invitationService.getInvitation(invitationId)) + .onSuccess(invitation -> { + if (invitation == null) { + context.respond(HttpStatus.NOT_FOUND, "No invitation found for ID " + invitationId); + } else { + context.respond(HttpStatus.OK, invitation); + } + }).onFailure(error -> context.respond(HttpStatus.INTERNAL_SERVER_ERROR, error.getMessage())); + } + return Future.succeededFuture(); + } + + public Future deleteInvitation(String invitationId) { + proxy.getVertx() + .executeBlocking(() -> { + String bucketLocation = BlobStorageUtil.buildInitiatorBucket(context); + String bucket = encryptionService.encrypt(bucketLocation); + invitationService.deleteInvitation(bucket, bucketLocation, invitationId); + return null; + }) + .onSuccess(ignore -> context.respond(HttpStatus.OK)) + .onFailure(error -> { + String errorMessage = error.getMessage(); + if (error instanceof PermissionDeniedException) { + context.respond(HttpStatus.FORBIDDEN, errorMessage); + } else if (error instanceof ResourceNotFoundException) { + context.respond(HttpStatus.NOT_FOUND, errorMessage); + } else { + context.respond(HttpStatus.INTERNAL_SERVER_ERROR, errorMessage); + } + }); + + return Future.succeededFuture(); + } +} diff --git a/src/main/java/com/epam/aidial/core/controller/ResourceController.java b/src/main/java/com/epam/aidial/core/controller/ResourceController.java index 347fcaf3c..927aa92e0 100644 --- a/src/main/java/com/epam/aidial/core/controller/ResourceController.java +++ b/src/main/java/com/epam/aidial/core/controller/ResourceController.java @@ -24,7 +24,8 @@ public class ResourceController extends AccessControlBaseController { private final boolean metadata; public ResourceController(Proxy proxy, ProxyContext context, boolean metadata) { - super(proxy, context, true); + // PUT and DELETE require full access, GET - not + super(proxy, context, !HttpMethod.GET.equals(context.getRequest().method())); this.vertx = proxy.getVertx(); this.service = proxy.getResourceService(); this.metadata = metadata; diff --git a/src/main/java/com/epam/aidial/core/controller/ShareController.java b/src/main/java/com/epam/aidial/core/controller/ShareController.java new file mode 100644 index 000000000..62c9942a7 --- /dev/null +++ b/src/main/java/com/epam/aidial/core/controller/ShareController.java @@ -0,0 +1,157 @@ +package com.epam.aidial.core.controller; + +import com.epam.aidial.core.Proxy; +import com.epam.aidial.core.ProxyContext; +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.ShareService; +import com.epam.aidial.core.storage.BlobStorageUtil; +import com.epam.aidial.core.util.HttpException; +import com.epam.aidial.core.util.HttpStatus; +import com.epam.aidial.core.util.ProxyUtil; +import io.vertx.core.Future; +import io.vertx.core.buffer.Buffer; +import lombok.extern.slf4j.Slf4j; + +import java.nio.charset.StandardCharsets; + +@Slf4j +public class ShareController { + + private static final String LIST_SHARED_BY_ME_RESOURCES = "others"; + + private final Proxy proxy; + private final ProxyContext context; + private final ShareService shareService; + private final EncryptionService encryptionService; + + public ShareController(Proxy proxy, ProxyContext context) { + this.proxy = proxy; + this.context = context; + this.shareService = proxy.getShareService(); + this.encryptionService = proxy.getEncryptionService(); + } + + + public Future handle(Operation operation) { + switch (operation) { + case LIST -> listSharedResources(); + case CREATE -> createSharedResources(); + case REVOKE -> revokeSharedResources(); + case DISCARD -> discardSharedResources(); + default -> + context.respond(HttpStatus.INTERNAL_SERVER_ERROR, "Operation %s is not supported".formatted(operation)); + } + return Future.succeededFuture(); + } + + public Future listSharedResources() { + return context.getRequest() + .body() + .compose(buffer -> { + ListSharedResourcesRequest request; + try { + String body = buffer.toString(StandardCharsets.UTF_8); + request = ProxyUtil.convertToObject(body, ListSharedResourcesRequest.class); + } catch (Exception e) { + log.error("Invalid request body provided", e); + throw new IllegalArgumentException("Can't list shared resources. Incorrect body"); + } + + String bucketLocation = BlobStorageUtil.buildInitiatorBucket(context); + String bucket = encryptionService.encrypt(bucketLocation); + String with = request.getWith(); + + return proxy.getVertx().executeBlocking(() -> { + if (LIST_SHARED_BY_ME_RESOURCES.equals(with)) { + return shareService.listSharedByMe(bucket, bucketLocation, request); + } else { + return shareService.listSharedWithMe(bucket, bucketLocation, request); + } + }); + }) + .onSuccess(response -> context.respond(HttpStatus.OK, response)) + .onFailure(this::handleServiceError); + } + + public Future createSharedResources() { + return context.getRequest() + .body() + .compose(buffer -> { + ShareResourcesRequest request; + try { + String body = buffer.toString(StandardCharsets.UTF_8); + request = ProxyUtil.convertToObject(body, ShareResourcesRequest.class); + } catch (Exception e) { + log.error("Invalid request body provided", e); + throw new IllegalArgumentException("Can't initiate share request. Incorrect body"); + } + + String bucketLocation = BlobStorageUtil.buildInitiatorBucket(context); + String bucket = encryptionService.encrypt(bucketLocation); + return proxy.getVertx().executeBlocking(() -> shareService.initializeShare(bucket, bucketLocation, request)); + }) + .onSuccess(response -> context.respond(HttpStatus.OK, response)) + .onFailure(this::handleServiceError); + } + + public Future discardSharedResources() { + return context.getRequest() + .body() + .compose(buffer -> { + ResourceLinkCollection request = getResourceLinkCollection(buffer, Operation.DISCARD); + String bucketLocation = BlobStorageUtil.buildInitiatorBucket(context); + String bucket = encryptionService.encrypt(bucketLocation); + return proxy.getVertx() + .executeBlocking(() -> { + shareService.discardSharedAccess(bucket, bucketLocation, request); + return null; + }); + }) + .onSuccess(response -> context.respond(HttpStatus.OK)) + .onFailure(this::handleServiceError); + } + + public Future revokeSharedResources() { + return context.getRequest() + .body() + .compose(buffer -> { + ResourceLinkCollection request = getResourceLinkCollection(buffer, Operation.REVOKE); + String bucketLocation = BlobStorageUtil.buildInitiatorBucket(context); + String bucket = encryptionService.encrypt(bucketLocation); + return proxy.getVertx() + .executeBlocking(() -> { + shareService.revokeSharedAccess(bucket, bucketLocation, request); + return null; + }); + }) + .onSuccess(response -> context.respond(HttpStatus.OK)) + .onFailure(this::handleServiceError); + } + + private void handleServiceError(Throwable error) { + if (error instanceof IllegalArgumentException) { + context.respond(HttpStatus.BAD_REQUEST, error.getMessage()); + } else if (error instanceof HttpException httpException) { + context.respond(httpException.getStatus(), httpException.getMessage()); + } else { + context.respond(HttpStatus.INTERNAL_SERVER_ERROR, error.getMessage()); + } + } + + private ResourceLinkCollection getResourceLinkCollection(Buffer buffer, Operation operation) { + try { + String body = buffer.toString(StandardCharsets.UTF_8); + return ProxyUtil.convertToObject(body, ResourceLinkCollection.class); + } catch (Exception e) { + log.error("Invalid request body provided", e); + throw new HttpException(HttpStatus.BAD_REQUEST, "Can't %s shared resources. Incorrect body".formatted(operation)); + } + } + + public enum Operation { + CREATE, LIST, DISCARD, REVOKE + } +} diff --git a/src/main/java/com/epam/aidial/core/data/Invitation.java b/src/main/java/com/epam/aidial/core/data/Invitation.java new file mode 100644 index 000000000..593a9ce2b --- /dev/null +++ b/src/main/java/com/epam/aidial/core/data/Invitation.java @@ -0,0 +1,17 @@ +package com.epam.aidial.core.data; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.Set; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class Invitation { + String id; + Set resources; + long createdAt; + long expireAt; +} diff --git a/src/main/java/com/epam/aidial/core/data/InvitationCollection.java b/src/main/java/com/epam/aidial/core/data/InvitationCollection.java new file mode 100644 index 000000000..65bcfbea0 --- /dev/null +++ b/src/main/java/com/epam/aidial/core/data/InvitationCollection.java @@ -0,0 +1,14 @@ +package com.epam.aidial.core.data; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.Set; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class InvitationCollection { + Set invitations; +} diff --git a/src/main/java/com/epam/aidial/core/data/InvitationLink.java b/src/main/java/com/epam/aidial/core/data/InvitationLink.java new file mode 100644 index 000000000..35fdecb82 --- /dev/null +++ b/src/main/java/com/epam/aidial/core/data/InvitationLink.java @@ -0,0 +1,4 @@ +package com.epam.aidial.core.data; + +public record InvitationLink(String invitationLink) { +} diff --git a/src/main/java/com/epam/aidial/core/data/InvitationType.java b/src/main/java/com/epam/aidial/core/data/InvitationType.java new file mode 100644 index 000000000..cc90a6764 --- /dev/null +++ b/src/main/java/com/epam/aidial/core/data/InvitationType.java @@ -0,0 +1,5 @@ +package com.epam.aidial.core.data; + +public enum InvitationType { + LINK, EMAIL +} diff --git a/src/main/java/com/epam/aidial/core/data/InvitationsMap.java b/src/main/java/com/epam/aidial/core/data/InvitationsMap.java new file mode 100644 index 000000000..602a20de5 --- /dev/null +++ b/src/main/java/com/epam/aidial/core/data/InvitationsMap.java @@ -0,0 +1,14 @@ +package com.epam.aidial.core.data; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.Map; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class InvitationsMap { + Map invitations; +} diff --git a/src/main/java/com/epam/aidial/core/data/ListSharedResourcesRequest.java b/src/main/java/com/epam/aidial/core/data/ListSharedResourcesRequest.java new file mode 100644 index 000000000..05ebbc749 --- /dev/null +++ b/src/main/java/com/epam/aidial/core/data/ListSharedResourcesRequest.java @@ -0,0 +1,21 @@ +package com.epam.aidial.core.data; + +import lombok.Data; + +import java.util.Set; + +@Data +public class ListSharedResourcesRequest { + /** + * Collection of resource types that user want to list + */ + Set resourceTypes; + /** + * Sorting order. Not implemented yet + */ + String order; + /** + * Shared resource direction. Can be either with - me or others. + */ + String with; +} diff --git a/src/main/java/com/epam/aidial/core/data/ResourceLink.java b/src/main/java/com/epam/aidial/core/data/ResourceLink.java new file mode 100644 index 000000000..580a3df0b --- /dev/null +++ b/src/main/java/com/epam/aidial/core/data/ResourceLink.java @@ -0,0 +1,37 @@ +package com.epam.aidial.core.data; + +import com.epam.aidial.core.storage.BlobStorageUtil; +import com.fasterxml.jackson.annotation.JsonIgnore; + +public record ResourceLink(String url) { + + @JsonIgnore + public ResourceType getResourceType() { + if (url == null) { + throw new IllegalStateException("Resource link can not be null"); + } + + String[] paths = url.split(BlobStorageUtil.PATH_SEPARATOR); + + if (paths.length < 2) { + throw new IllegalStateException("Invalid resource link provided: " + url); + } + + return ResourceType.of(paths[0]); + } + + @JsonIgnore + public String getBucket() { + if (url == null) { + throw new IllegalStateException("Resource link can not be null"); + } + + String[] paths = url.split(BlobStorageUtil.PATH_SEPARATOR); + + if (paths.length < 2) { + throw new IllegalStateException("Invalid resource link provided: " + url); + } + + return paths[1]; + } +} diff --git a/src/main/java/com/epam/aidial/core/data/ResourceLinkCollection.java b/src/main/java/com/epam/aidial/core/data/ResourceLinkCollection.java new file mode 100644 index 000000000..242e735fa --- /dev/null +++ b/src/main/java/com/epam/aidial/core/data/ResourceLinkCollection.java @@ -0,0 +1,14 @@ +package com.epam.aidial.core.data; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.Set; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class ResourceLinkCollection { + Set resources; +} diff --git a/src/main/java/com/epam/aidial/core/data/ResourceType.java b/src/main/java/com/epam/aidial/core/data/ResourceType.java index d88e5163f..ade1d718f 100644 --- a/src/main/java/com/epam/aidial/core/data/ResourceType.java +++ b/src/main/java/com/epam/aidial/core/data/ResourceType.java @@ -4,7 +4,8 @@ @Getter public enum ResourceType { - FILE("files"), CONVERSATION("conversations"), PROMPT("prompts"), LIMIT("limits"); + FILE("files"), CONVERSATION("conversations"), PROMPT("prompts"), LIMIT("limits"), + SHARED_WITH_ME("shared_with_me"), SHARED_BY_ME("shared_by_me"), INVITATION("invitations"); private final String group; @@ -21,6 +22,7 @@ public static ResourceType of(String group) { case "files" -> FILE; case "conversations" -> CONVERSATION; case "prompts" -> PROMPT; + case "invitations" -> INVITATION; default -> throw new IllegalArgumentException("Unsupported group: " + group); }; } diff --git a/src/main/java/com/epam/aidial/core/data/ShareResourcesRequest.java b/src/main/java/com/epam/aidial/core/data/ShareResourcesRequest.java new file mode 100644 index 000000000..b96ac0ceb --- /dev/null +++ b/src/main/java/com/epam/aidial/core/data/ShareResourcesRequest.java @@ -0,0 +1,11 @@ +package com.epam.aidial.core.data; + +import lombok.Data; + +import java.util.Set; + +@Data +public class ShareResourcesRequest { + Set resources; + InvitationType invitationType; +} diff --git a/src/main/java/com/epam/aidial/core/data/SharedByMeDto.java b/src/main/java/com/epam/aidial/core/data/SharedByMeDto.java new file mode 100644 index 000000000..cb4758a57 --- /dev/null +++ b/src/main/java/com/epam/aidial/core/data/SharedByMeDto.java @@ -0,0 +1,21 @@ +package com.epam.aidial.core.data; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class SharedByMeDto { + Map> resourceToUsers; + + public void addUserToResource(String url, String userLocation) { + Set users = resourceToUsers.computeIfAbsent(url, k -> new HashSet<>()); + users.add(userLocation); + } +} diff --git a/src/main/java/com/epam/aidial/core/data/SharedResourcesResponse.java b/src/main/java/com/epam/aidial/core/data/SharedResourcesResponse.java new file mode 100644 index 000000000..eeafe2723 --- /dev/null +++ b/src/main/java/com/epam/aidial/core/data/SharedResourcesResponse.java @@ -0,0 +1,14 @@ +package com.epam.aidial.core.data; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.Set; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class SharedResourcesResponse { + Set resources; +} diff --git a/src/main/java/com/epam/aidial/core/service/InvitationService.java b/src/main/java/com/epam/aidial/core/service/InvitationService.java new file mode 100644 index 000000000..97538b95d --- /dev/null +++ b/src/main/java/com/epam/aidial/core/service/InvitationService.java @@ -0,0 +1,163 @@ +package com.epam.aidial.core.service; + +import com.epam.aidial.core.data.Invitation; +import com.epam.aidial.core.data.InvitationCollection; +import com.epam.aidial.core.data.InvitationsMap; +import com.epam.aidial.core.data.ResourceLink; +import com.epam.aidial.core.data.ResourceType; +import com.epam.aidial.core.security.ApiKeyGenerator; +import com.epam.aidial.core.security.EncryptionService; +import com.epam.aidial.core.storage.BlobStorageUtil; +import com.epam.aidial.core.storage.ResourceDescription; +import com.epam.aidial.core.util.ProxyUtil; +import io.vertx.core.json.JsonObject; +import lombok.extern.slf4j.Slf4j; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import javax.annotation.Nullable; + +@Slf4j +public class InvitationService { + + private static final InvitationCollection EMPTY_INVITATION_COLLECTION = new InvitationCollection(Set.of()); + + private static final String INVITATION_RESOURCE_FILENAME = "invitations"; + private static final int DEFAULT_INVITATION_TTL_IN_SECONDS = 259_200; + static final String INVITATION_PATH_BASE = "/v1/invitations"; + + private final ResourceService resourceService; + private final EncryptionService encryptionService; + private final int expirationInSeconds; + + public InvitationService(ResourceService resourceService, EncryptionService encryptionService, JsonObject settings) { + this.resourceService = resourceService; + this.encryptionService = encryptionService; + this.expirationInSeconds = settings.getInteger("ttlInSeconds", DEFAULT_INVITATION_TTL_IN_SECONDS); + } + + public Invitation createInvitation(String bucket, String location, Set resources) { + ResourceDescription resource = ResourceDescription.fromDecoded(ResourceType.INVITATION, bucket, location, INVITATION_RESOURCE_FILENAME); + String invitationId = generateInvitationId(resource); + Instant creationTime = Instant.now(); + Instant expirationTime = Instant.now().plus(expirationInSeconds, ChronoUnit.SECONDS); + Invitation invitation = new Invitation(invitationId, resources, creationTime.toEpochMilli(), expirationTime.toEpochMilli()); + + resourceService.computeResource(resource, state -> { + InvitationsMap invitations = ProxyUtil.convertToObject(state, InvitationsMap.class); + if (invitations == null) { + invitations = new InvitationsMap(new HashMap<>()); + } + invitations.getInvitations().put(invitationId, invitation); + + return ProxyUtil.convertToString(invitations); + }); + + return invitation; + } + + @Nullable + public Invitation getInvitation(String invitationId) { + ResourceDescription resource = getInvitationResource(invitationId); + if (resource == null) { + return null; + } + String resourceState = resourceService.getResource(resource); + InvitationsMap invitations = ProxyUtil.convertToObject(resourceState, InvitationsMap.class); + if (invitations == null) { + return null; + } + + Invitation invitation = invitations.getInvitations().get(invitationId); + if (invitation == null) { + return null; + } + + Instant expireAt = Instant.ofEpochMilli(invitation.getExpireAt()); + if (Instant.now().isAfter(expireAt)) { + // invitation expired - we need to clean up state + cleanUpExpiredInvitations(resource, List.of(invitationId)); + return null; + } + + return invitation; + } + + public void deleteInvitation(String bucket, String location, String invitationId) { + ResourceDescription resource = getInvitationResource(invitationId); + if (resource == null) { + throw new ResourceNotFoundException("No invitation found for ID" + invitationId); + } + // deny operation if caller is not an owner + if (!resource.getBucketName().equals(bucket)) { + throw new PermissionDeniedException("You are not invitation owner"); + } + cleanUpExpiredInvitations(resource, List.of(invitationId)); + } + + public InvitationCollection getMyInvitations(String bucket, String location) { + ResourceDescription resource = ResourceDescription.fromDecoded(ResourceType.INVITATION, bucket, location, INVITATION_RESOURCE_FILENAME); + String state = resourceService.getResource(resource); + InvitationsMap invitationMap = ProxyUtil.convertToObject(state, InvitationsMap.class); + if (invitationMap == null || invitationMap.getInvitations().isEmpty()) { + return EMPTY_INVITATION_COLLECTION; + } + + Collection invitations = invitationMap.getInvitations().values(); + Instant currentTime = Instant.now(); + Set invitationsToEvict = invitations.stream() + .filter(invitation -> currentTime.isAfter(Instant.ofEpochMilli(invitation.getExpireAt()))) + .map(Invitation::getId) + .collect(Collectors.toSet()); + + if (!invitationsToEvict.isEmpty()) { + cleanUpExpiredInvitations(resource, invitationsToEvict); + invitationsToEvict.forEach(invitationToEvict -> invitationMap.getInvitations().remove(invitationToEvict)); + } + + return new InvitationCollection(new HashSet<>(invitationMap.getInvitations().values())); + } + + private void cleanUpExpiredInvitations(ResourceDescription resource, Collection idsToEvict) { + resourceService.computeResource(resource, state -> { + InvitationsMap invitations = ProxyUtil.convertToObject(state, InvitationsMap.class); + if (invitations == null) { + invitations = new InvitationsMap(new HashMap<>()); + } + Map invitationMap = invitations.getInvitations(); + idsToEvict.forEach(invitationMap::remove); + + return ProxyUtil.convertToString(invitations); + }); + } + + @Nullable + private ResourceDescription getInvitationResource(String invitationId) { + // decrypt invitation ID to obtain its location + String decryptedInvitationPath = encryptionService.decrypt(invitationId); + if (decryptedInvitationPath == null) { + return null; + } + + String[] parts = decryptedInvitationPath.split(BlobStorageUtil.PATH_SEPARATOR); + // due to current design decoded resource location looks like: Users//invitations/invitations.json/ + if (parts.length != 5) { + return null; + } + String location = parts[0] + BlobStorageUtil.PATH_SEPARATOR + parts[1] + BlobStorageUtil.PATH_SEPARATOR; + String bucket = encryptionService.encrypt(location); + ResourceType resourceType = ResourceType.of(parts[2]); + return ResourceDescription.fromDecoded(resourceType, bucket, location, INVITATION_RESOURCE_FILENAME); + } + + private String generateInvitationId(ResourceDescription resource) { + return encryptionService.encrypt(resource.getAbsoluteFilePath() + BlobStorageUtil.PATH_SEPARATOR + ApiKeyGenerator.generateKey()); + } +} diff --git a/src/main/java/com/epam/aidial/core/service/PermissionDeniedException.java b/src/main/java/com/epam/aidial/core/service/PermissionDeniedException.java new file mode 100644 index 000000000..75288ee23 --- /dev/null +++ b/src/main/java/com/epam/aidial/core/service/PermissionDeniedException.java @@ -0,0 +1,15 @@ +package com.epam.aidial.core.service; + +public class PermissionDeniedException extends RuntimeException { + + public PermissionDeniedException() { + } + + public PermissionDeniedException(String message) { + super(message); + } + + public PermissionDeniedException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/com/epam/aidial/core/service/ResourceNotFoundException.java b/src/main/java/com/epam/aidial/core/service/ResourceNotFoundException.java new file mode 100644 index 000000000..34bd54aa4 --- /dev/null +++ b/src/main/java/com/epam/aidial/core/service/ResourceNotFoundException.java @@ -0,0 +1,15 @@ +package com.epam.aidial.core.service; + +public class ResourceNotFoundException extends RuntimeException { + + public ResourceNotFoundException() { + } + + public ResourceNotFoundException(String message) { + super(message); + } + + public ResourceNotFoundException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/com/epam/aidial/core/service/ShareService.java b/src/main/java/com/epam/aidial/core/service/ShareService.java new file mode 100644 index 000000000..53bda892b --- /dev/null +++ b/src/main/java/com/epam/aidial/core/service/ShareService.java @@ -0,0 +1,324 @@ +package com.epam.aidial.core.service; + +import com.epam.aidial.core.data.Invitation; +import com.epam.aidial.core.data.InvitationLink; +import com.epam.aidial.core.data.ListSharedResourcesRequest; +import com.epam.aidial.core.data.MetadataBase; +import com.epam.aidial.core.data.ResourceFolderMetadata; +import com.epam.aidial.core.data.ResourceItemMetadata; +import com.epam.aidial.core.data.ResourceLink; +import com.epam.aidial.core.data.ResourceLinkCollection; +import com.epam.aidial.core.data.ResourceType; +import com.epam.aidial.core.data.ShareResourcesRequest; +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.BlobStorageUtil; +import com.epam.aidial.core.storage.ResourceDescription; +import com.epam.aidial.core.util.ProxyUtil; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +@AllArgsConstructor +@Slf4j +public class ShareService { + + private static final String SHARE_RESOURCE_FILENAME = "share"; + + private final ResourceService resourceService; + private final InvitationService invitationService; + private final EncryptionService encryptionService; + + /** + * Returns a list of resources shared with user. + * + * @param bucket - user bucket + * @param location - storage location + * @param request - request body + * @return list of shared with user resources + */ + public SharedResourcesResponse listSharedWithMe(String bucket, String location, ListSharedResourcesRequest request) { + Set requestedResourceType = request.getResourceTypes(); + + Set shareResources = new HashSet<>(); + for (ResourceType resourceType : requestedResourceType) { + ResourceDescription sharedResource = getShareResource(ResourceType.SHARED_WITH_ME, resourceType, bucket, location); + shareResources.add(sharedResource); + } + + Set resultMetadata = new HashSet<>(); + for (ResourceDescription resource : shareResources) { + String sharedResource = resourceService.getResource(resource); + ResourceLinkCollection resourceLinksCollection = ProxyUtil.convertToObject(sharedResource, ResourceLinkCollection.class); + if (resourceLinksCollection != null && resourceLinksCollection.getResources() != null) { + resultMetadata.addAll(linksToMetadata(resourceLinksCollection.getResources().stream().map(ResourceLink::url))); + } + } + + return new SharedResourcesResponse(resultMetadata); + } + + /** + * Returns list of resources shared by user. + * + * @param bucket - user bucket + * @param location - storage location + * @param request - request body + * @return list of shared with user resources + */ + public SharedResourcesResponse listSharedByMe(String bucket, String location, ListSharedResourcesRequest request) { + Set requestedResourceTypes = request.getResourceTypes(); + + Set shareResources = new HashSet<>(); + for (ResourceType resourceType : requestedResourceTypes) { + ResourceDescription shareResource = getShareResource(ResourceType.SHARED_BY_ME, resourceType, bucket, location); + shareResources.add(shareResource); + } + + Set resultMetadata = new HashSet<>(); + for (ResourceDescription resource : shareResources) { + String sharedResource = resourceService.getResource(resource); + SharedByMeDto resourceToUsers = ProxyUtil.convertToObject(sharedResource, SharedByMeDto.class); + if (resourceToUsers != null && resourceToUsers.getResourceToUsers() != null) { + resultMetadata.addAll(linksToMetadata(resourceToUsers.getResourceToUsers().keySet().stream())); + } + } + + return new SharedResourcesResponse(resultMetadata); + } + + /** + * Initialize share request by creating invitation object + * + * @param bucket - user bucket + * @param location - storage location + * @param request - request body + * @return invitation link + */ + public InvitationLink initializeShare(String bucket, String location, ShareResourcesRequest request) { + // validate resources - owner must be current user + Set resourceLinks = request.getResources(); + if (resourceLinks.isEmpty()) { + throw new IllegalArgumentException("No resources provided"); + } + + for (ResourceLink resourceLink : resourceLinks) { + String url = resourceLink.url(); + ResourceDescription resource = getResourceFromLink(url); + if (!bucket.equals(resource.getBucketName())) { + throw new IllegalArgumentException("Resource %s does not belong to the user".formatted(url)); + } + } + + Invitation invitation = invitationService.createInvitation(bucket, location, resourceLinks); + return new InvitationLink(InvitationService.INVITATION_PATH_BASE + BlobStorageUtil.PATH_SEPARATOR + invitation.getId()); + } + + /** + * Accept an invitation to grand share access for provided resources + * + * @param bucket - user bucket + * @param location - storage location + * @param invitationId - invitation ID + */ + public void acceptSharedResources(String bucket, String location, String invitationId) { + Invitation invitation = invitationService.getInvitation(invitationId); + + if (invitation == null) { + throw new ResourceNotFoundException("No invitation found with id: " + invitationId); + } + + Set resourceLinks = invitation.getResources(); + + for (ResourceLink link : resourceLinks) { + String url = link.url(); + if (ResourceDescription.fromLink(url, encryptionService).getBucketName().equals(bucket)) { + throw new IllegalArgumentException("Resource %s already belong to you".formatted(url)); + } + } + + // group resources with the same type to reduce resource transformations + Map> resourceGroups = resourceLinks.stream() + .collect(Collectors.groupingBy(ResourceLink::getResourceType)); + + for (Map.Entry> group : resourceGroups.entrySet()) { + ResourceType resourceType = group.getKey(); + List links = group.getValue(); + String ownerBucket = links.get(0).getBucket(); + String ownerLocation = encryptionService.decrypt(ownerBucket); + + // write user location to the resource owner + ResourceDescription sharedByMe = getShareResource(ResourceType.SHARED_BY_ME, resourceType, ownerBucket, ownerLocation); + resourceService.computeResource(sharedByMe, state -> { + SharedByMeDto dto = ProxyUtil.convertToObject(state, SharedByMeDto.class); + if (dto == null) { + dto = new SharedByMeDto(new HashMap<>()); + } + + // add user location for each link + for (ResourceLink resourceLink : links) { + dto.addUserToResource(resourceLink.url(), location); + } + + return ProxyUtil.convertToString(dto); + }); + + ResourceDescription sharedWithMe = getShareResource(ResourceType.SHARED_WITH_ME, resourceType, bucket, location); + resourceService.computeResource(sharedWithMe, state -> { + ResourceLinkCollection collection = ProxyUtil.convertToObject(state, ResourceLinkCollection.class); + if (collection == null) { + collection = new ResourceLinkCollection(new HashSet<>()); + } + + // add all links to the user + collection.getResources().addAll(links); + + return ProxyUtil.convertToString(collection); + }); + } + } + + public boolean hasReadAccess(String bucket, String location, ResourceDescription resource) { + ResourceDescription shareResource = getShareResource(ResourceType.SHARED_WITH_ME, resource.getType(), bucket, location); + + String state = resourceService.getResource(shareResource); + ResourceLinkCollection sharedResources = ProxyUtil.convertToObject(state, ResourceLinkCollection.class); + if (sharedResources == null) { + log.debug("No state found for share access"); + return false; + } + + Set sharedLinks = sharedResources.getResources(); + if (sharedLinks.contains(new ResourceLink(resource.getUrl()))) { + return true; + } + + // check if you have shared access to the parent folder + ResourceDescription parentFolder = resource.getParent(); + while (parentFolder != null) { + if (sharedLinks.contains(new ResourceLink(parentFolder.getUrl()))) { + return true; + } + parentFolder = parentFolder.getParent(); + } + + return false; + } + + /** + * Revoke share access for provided resources. Only resource owner can perform this operation + * + * @param bucket - user bucket + * @param location - storage location + * @param resources - collection of links to revoke access + */ + public void revokeSharedAccess(String bucket, String location, ResourceLinkCollection resources) { + Set resourceLinks = resources.getResources(); + if (resourceLinks.isEmpty()) { + throw new IllegalArgumentException("No resources provided"); + } + + // validate that all resources belong to the user, who perform this action + for (ResourceLink link : resourceLinks) { + ResourceDescription resource = getResourceFromLink(link.url()); + if (!resource.getBucketName().equals(bucket)) { + throw new IllegalArgumentException("You are only allowed to revoke access from own resources"); + } + } + + for (ResourceLink link : resourceLinks) { + ResourceType resourceType = link.getResourceType(); + ResourceDescription sharedByMeResource = getShareResource(ResourceType.SHARED_BY_ME, resourceType, bucket, location); + String state = resourceService.getResource(sharedByMeResource); + SharedByMeDto dto = ProxyUtil.convertToObject(state, SharedByMeDto.class); + if (dto != null) { + Set userLocations = dto.getResourceToUsers().get(link.url()); + + for (String userLocation : userLocations) { + String userBucket = encryptionService.encrypt(userLocation); + removeSharedResource(userBucket, userLocation, link, resourceType); + } + + resourceService.computeResource(sharedByMeResource, ownerState -> { + SharedByMeDto sharedByMeDto = ProxyUtil.convertToObject(state, SharedByMeDto.class); + if (sharedByMeDto != null) { + sharedByMeDto.getResourceToUsers().remove(link.url()); + } + + return ProxyUtil.convertToString(sharedByMeDto); + }); + } + } + } + + public void discardSharedAccess(String bucket, String location, ResourceLinkCollection resources) { + Set resourceLinks = resources.getResources(); + if (resourceLinks.isEmpty()) { + throw new IllegalArgumentException("No resources provided"); + } + + for (ResourceLink link : resourceLinks) { + ResourceDescription resource = getResourceFromLink(link.url()); + ResourceType resourceType = resource.getType(); + removeSharedResource(bucket, location, link, resourceType); + + String ownerBucket = link.getBucket(); + String ownerLocation = encryptionService.decrypt(ownerBucket); + + ResourceDescription sharedWithMe = getShareResource(ResourceType.SHARED_BY_ME, resourceType, ownerBucket, ownerLocation); + resourceService.computeResource(sharedWithMe, ownerState -> { + SharedByMeDto sharedByMeDto = ProxyUtil.convertToObject(ownerState, SharedByMeDto.class); + if (sharedByMeDto != null) { + sharedByMeDto.getResourceToUsers().get(link.url()).remove(location); + } + + return ProxyUtil.convertToString(sharedByMeDto); + }); + } + } + + private void removeSharedResource(String bucket, String location, ResourceLink link, ResourceType resourceType) { + 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().remove(link); + } + + return ProxyUtil.convertToString(sharedWithMe); + }); + } + + private List linksToMetadata(Stream links) { + return links + .map(link -> ResourceDescription.fromLink(link, encryptionService)) + .map(resource -> { + if (resource.isFolder()) { + return new ResourceFolderMetadata(resource); + } else { + return new ResourceItemMetadata(resource); + } + }).toList(); + } + + private ResourceDescription getResourceFromLink(String url) { + try { + return ResourceDescription.fromLink(url, encryptionService); + } catch (Exception e) { + throw new IllegalArgumentException("Incorrect resource link provided " + url); + } + } + + private static 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/storage/ResourceDescription.java b/src/main/java/com/epam/aidial/core/storage/ResourceDescription.java index a74ba2ddb..6d6fa8ecc 100644 --- a/src/main/java/com/epam/aidial/core/storage/ResourceDescription.java +++ b/src/main/java/com/epam/aidial/core/storage/ResourceDescription.java @@ -1,6 +1,7 @@ package com.epam.aidial.core.storage; import com.epam.aidial.core.data.ResourceType; +import com.epam.aidial.core.security.EncryptionService; import com.epam.aidial.core.util.UrlUtil; import lombok.AccessLevel; import lombok.AllArgsConstructor; @@ -11,6 +12,7 @@ import java.util.Arrays; import java.util.List; import java.util.stream.Collectors; +import javax.annotation.Nullable; @Data @AllArgsConstructor(access = AccessLevel.PRIVATE) @@ -74,14 +76,24 @@ public String getAbsoluteFilePath() { return builder.toString(); } - public String getParentPath() { - return parentFolders.isEmpty() ? null : String.join(BlobStorageUtil.PATH_SEPARATOR, parentFolders); + @Nullable + public ResourceDescription getParent() { + if (parentFolders.isEmpty()) { + return null; + } + + String parentFolderName = parentFolders.get(parentFolders.size() - 1); + return new ResourceDescription(type, parentFolderName, parentFolders.subList(0, parentFolders.size() - 1), originalPath, bucketName, bucketLocation, true); } public boolean isRootFolder() { return isFolder && name == null; } + public String getParentPath() { + return parentFolders.isEmpty() ? null : String.join(BlobStorageUtil.PATH_SEPARATOR, parentFolders); + } + /** * @param type resource type * @param bucketName bucket name (encrypted) @@ -131,6 +143,24 @@ public static ResourceDescription fromDecoded(ResourceDescription description, S return fromDecoded(description.getType(), description.getBucketName(), description.getBucketLocation(), relativePath); } + public static ResourceDescription fromLink(String link, EncryptionService encryptionService) { + String[] parts = link.split(BlobStorageUtil.PATH_SEPARATOR); + + if (parts.length < 2) { + throw new IllegalArgumentException("Invalid resource link provided " + link); + } + + ResourceType resourceType = ResourceType.of(parts[0]); + String bucket = parts[1]; + String location = encryptionService.decrypt(bucket); + if (location == null) { + throw new IllegalArgumentException("Unknown bucket " + bucket); + } + + String resourcePath = link.substring(bucket.length() + parts[0].length() + 2); + return fromEncoded(resourceType, bucket, location, resourcePath); + } + private static ResourceDescription from(ResourceType type, String bucketName, String bucketLocation, String originalPath, List paths, boolean isFolder) { boolean isEmptyElements = paths.isEmpty(); diff --git a/src/main/java/com/epam/aidial/core/util/ProxyUtil.java b/src/main/java/com/epam/aidial/core/util/ProxyUtil.java index 826a0667e..f9a91e147 100644 --- a/src/main/java/com/epam/aidial/core/util/ProxyUtil.java +++ b/src/main/java/com/epam/aidial/core/util/ProxyUtil.java @@ -2,6 +2,7 @@ import com.epam.aidial.core.Proxy; import com.epam.aidial.core.config.ApiKeyData; +import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.MapperFeature; import com.fasterxml.jackson.databind.json.JsonMapper; @@ -14,6 +15,7 @@ import lombok.experimental.UtilityClass; import java.util.Map; +import javax.annotation.Nullable; @UtilityClass public class ProxyUtil { @@ -105,4 +107,29 @@ public static void collectAttachedFiles(ObjectNode tree, ApiKeyData apiKeyData) } } } + + @Nullable + public static T convertToObject(String payload, Class clazz) { + if (payload == null) { + return null; + } + try { + return MAPPER.readValue(payload, clazz); + } catch (JsonProcessingException e) { + throw new IllegalArgumentException(e); + } + } + + @Nullable + public static String convertToString(Object data) { + if (data == null) { + return null; + } + + try { + return MAPPER.writeValueAsString(data); + } catch (JsonProcessingException e) { + throw new IllegalArgumentException(e); + } + } } diff --git a/src/test/java/com/epam/aidial/core/FileApiTest.java b/src/test/java/com/epam/aidial/core/FileApiTest.java index 05631f5d3..c4d0505c2 100644 --- a/src/test/java/com/epam/aidial/core/FileApiTest.java +++ b/src/test/java/com/epam/aidial/core/FileApiTest.java @@ -211,7 +211,7 @@ public void testInvalidFileUploadUrl2(Vertx vertx, VertxTestContext context) { context.succeeding(response -> { context.verify(() -> { assertEquals(403, response.statusCode()); - assertEquals("You don't have an access to the bucket testbucket", response.body()); + assertEquals("You don't have an access to the FILE testbucket/", response.body()); context.completeNow(); }); }) @@ -397,7 +397,8 @@ public void testFileUploadToAppdata(Vertx vertx, VertxTestContext context) { context.succeeding(response -> { context.verify(() -> { assertEquals(403, response.statusCode()); - assertEquals("You don't have an access to the bucket 3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST", response.body()); + assertEquals("You don't have an access to the FILE 3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST/appdata/EPM-RTC-RAIL/file.txt", + response.body()); checkpoint.flag(); promise.complete(); }); @@ -467,7 +468,7 @@ public void testDownloadSharedFile(Vertx vertx, VertxTestContext context) { .send(context.succeeding(response -> { context.verify(() -> { assertEquals(403, response.statusCode()); - assertEquals("You don't have an access to the bucket 7G9WZNcoY26Vy9D7bEgbv6zqbJGfyDp9KZyEbJR4XMZt", response.body()); + assertEquals("You don't have an access to the FILE 7G9WZNcoY26Vy9D7bEgbv6zqbJGfyDp9KZyEbJR4XMZt/folder1/file.txt", response.body()); checkpoint.flag(); promise.complete(); }); diff --git a/src/test/java/com/epam/aidial/core/InvitationApiTest.java b/src/test/java/com/epam/aidial/core/InvitationApiTest.java new file mode 100644 index 000000000..db102ce17 --- /dev/null +++ b/src/test/java/com/epam/aidial/core/InvitationApiTest.java @@ -0,0 +1,68 @@ +package com.epam.aidial.core; + +import com.epam.aidial.core.data.InvitationLink; +import com.epam.aidial.core.storage.BlobStorageUtil; +import com.epam.aidial.core.util.ProxyUtil; +import io.vertx.core.http.HttpMethod; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertNotNull; + +public class InvitationApiTest extends ResourceBaseTest { + + @Test + public void testInvitations() { + Response response = send(HttpMethod.GET, "/v1/invitations", null, null); + verifyJson(response, 200, """ + { + "invitations": [] + } + """); + + response = operationRequest("/v1/ops/resource/share/create", """ + { + "invitationType": "link", + "resources": [ + { + "url": "conversations/3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST/folder/conversation%201" + } + ] + } + """); + verify(response, 200); + InvitationLink invitationLink = ProxyUtil.convertToObject(response.body(), InvitationLink.class); + assertNotNull(invitationLink); + + String[] elements = invitationLink.invitationLink().split(BlobStorageUtil.PATH_SEPARATOR); + String invitationId = elements[elements.length - 1]; + + // verify invitation can be listed + response = send(HttpMethod.GET, "/v1/invitations", null, null); + verifyNotExact(response, 200, "\"id\":\"" + invitationId + "\""); + + // get invitation details by ID + response = send(HttpMethod.GET, "/v1/invitations/" + invitationId, null, null); + verifyNotExact(response, 200, "\"id\":\"" + invitationId + "\""); + + // get invitation details by ID + response = send(HttpMethod.GET, "/v1/invitations/" + invitationId, null, null, "Api-key", "proxyKey2"); + verifyNotExact(response, 200, "\"id\":\"" + invitationId + "\""); + + // delete invitation + response = send(HttpMethod.DELETE, "/v1/invitations/" + invitationId, null, null, "Api-key", "proxyKey2"); + verify(response, 403, "You are not invitation owner"); + + // delete invitation + response = send(HttpMethod.DELETE, "/v1/invitations/" + invitationId, null, null); + verify(response, 200); + } + + @Test + public void testInvitationNotFound() { + Response response = send(HttpMethod.GET, "/v1/invitations/asdasd", null, null); + verify(response, 404); + + response = send(HttpMethod.DELETE, "/v1/invitations/asdasd", null, null); + verify(response, 404); + } +} diff --git a/src/test/java/com/epam/aidial/core/ResourceApiTest.java b/src/test/java/com/epam/aidial/core/ResourceApiTest.java index d184e0241..a8ef3e6c2 100644 --- a/src/test/java/com/epam/aidial/core/ResourceApiTest.java +++ b/src/test/java/com/epam/aidial/core/ResourceApiTest.java @@ -1,125 +1,17 @@ package com.epam.aidial.core; import io.vertx.core.http.HttpMethod; -import io.vertx.core.json.JsonObject; -import lombok.SneakyThrows; import lombok.extern.slf4j.Slf4j; -import org.apache.http.client.methods.CloseableHttpResponse; -import org.apache.http.client.methods.HttpDelete; -import org.apache.http.client.methods.HttpGet; -import org.apache.http.client.methods.HttpPut; -import org.apache.http.client.methods.HttpUriRequest; -import org.apache.http.entity.StringEntity; -import org.apache.http.impl.client.CloseableHttpClient; -import org.apache.http.impl.client.HttpClientBuilder; -import org.apache.http.util.EntityUtils; -import org.junit.jupiter.api.AfterAll; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import redis.embedded.RedisServer; -import java.nio.file.Path; import java.util.concurrent.ThreadLocalRandom; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; - @Slf4j -class ResourceApiTest { - - private static RedisServer redis; - private static AiDial dial; - private static Path testDir; - private static CloseableHttpClient client; - private String bucket; - - @BeforeAll - static void init() throws Exception { - try { - testDir = FileUtil.baseTestPath(ResourceApiTest.class); - FileUtil.createDir(testDir.resolve("test")); - - redis = RedisServer.builder() - .port(16370) - .setting("bind 127.0.0.1") - .setting("maxmemory 16M") - .setting("maxmemory-policy volatile-lfu") - .build(); - redis.start(); - - client = HttpClientBuilder.create().build(); - - String overrides = """ - { - "client": { - "connectTimeout": 5000 - }, - "storage": { - "bucket": "test", - "provider": "filesystem", - "identity": "access-key", - "credential": "secret-key", - "overrides": { - "jclouds.filesystem.basedir": "%s" - } - }, - "redis": { - "singleServerConfig": { - "address": "redis://localhost:16370" - } - }, - "resources": { - "syncPeriod": 100, - "syncDelay": 100, - "cacheExpiration": 100 - } - } - """.formatted(testDir); - - JsonObject settings = AiDial.settings() - .mergeIn(new JsonObject(overrides), true); - - dial = new AiDial(); - dial.setSettings(settings); - dial.start(); - } catch (Throwable e) { - destroy(); - throw e; - } - } - - @AfterAll - static void destroy() throws Exception { - try { - if (client != null) { - client.close(); - } - - if (dial != null) { - dial.stop(); - } - } finally { - if (redis != null) { - redis.stop(); - } - - FileUtil.deleteDir(testDir); - } - } - - @BeforeEach - void setUp() { - Response response = send(HttpMethod.GET, "/v1/bucket", ""); - assertEquals(response.status, 200); - bucket = new JsonObject(response.body).getString("bucket"); - assertNotNull(bucket); - } +class ResourceApiTest extends ResourceBaseTest { @Test void testWorkflow() { - Response response = request(HttpMethod.GET, "/folder/conversation"); + Response response = resourceRequest(HttpMethod.GET, "/folder/conversation"); verify(response, 404, "Not found: conversations/3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST/folder/conversation"); response = metadata("/folder/"); @@ -128,22 +20,22 @@ void testWorkflow() { response = metadata("/"); verifyNotExact(response, 200, "\"url\":\"conversations/3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST/\""); - response = request(HttpMethod.PUT, "/folder/conversation", "12345"); + response = resourceRequest(HttpMethod.PUT, "/folder/conversation", "12345"); verifyNotExact(response, 200, "\"url\":\"conversations/3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST/folder/conversation\""); response = metadata("/?recursive=true"); verifyNotExact(response, 200, "\"url\":\"conversations/3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST/folder/conversation\""); - response = request(HttpMethod.PUT, "/folder/conversation", "12345", "if-none-match", "*"); + response = resourceRequest(HttpMethod.PUT, "/folder/conversation", "12345", "if-none-match", "*"); verifyNotExact(response, 409, "Resource already exists: conversations/3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST/folder/conversation"); - response = request(HttpMethod.GET, "/folder/conversation"); + response = resourceRequest(HttpMethod.GET, "/folder/conversation"); verify(response, 200, "12345"); - response = request(HttpMethod.PUT, "/folder/conversation", "123456"); + response = resourceRequest(HttpMethod.PUT, "/folder/conversation", "123456"); verifyNotExact(response, 200, "\"url\":\"conversations/3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST/folder"); - response = request(HttpMethod.GET, "/folder/conversation"); + response = resourceRequest(HttpMethod.GET, "/folder/conversation"); verify(response, 200, "123456"); response = metadata("/folder/"); @@ -152,13 +44,13 @@ void testWorkflow() { response = metadata("/"); verifyNotExact(response, 200, "\"url\":\"conversations/3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST/folder/\""); - response = request(HttpMethod.DELETE, "/folder/conversation"); + response = resourceRequest(HttpMethod.DELETE, "/folder/conversation"); verify(response, 200, ""); - response = request(HttpMethod.GET, "/folder/conversation"); + response = resourceRequest(HttpMethod.GET, "/folder/conversation"); verify(response, 404, "Not found: conversations/3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST/folder/conversation"); - response = request(HttpMethod.DELETE, "/folder/conversation"); + response = resourceRequest(HttpMethod.DELETE, "/folder/conversation"); verify(response, 404, "Not found: conversations/3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST/folder/conversation"); response = metadata("/folder/"); @@ -167,19 +59,19 @@ void testWorkflow() { @Test void testMaxKeySize() { - Response response = request(HttpMethod.PUT, "/" + "1".repeat(900), "body"); + Response response = resourceRequest(HttpMethod.PUT, "/" + "1".repeat(900), "body"); verify(response, 400, "Resource path exceeds max allowed size: 900"); } @Test void testMaxContentSize() { - Response response = request(HttpMethod.PUT, "/folder/big", "1".repeat(1024 * 1024 + 1)); + Response response = resourceRequest(HttpMethod.PUT, "/folder/big", "1".repeat(1024 * 1024 + 1)); verify(response, 413, "Resource size: 1048577 exceeds max limit: 1048576"); } @Test void testUnsupportedIfNoneMatchHeader() { - Response response = request(HttpMethod.PUT, "/folder/big", "1", "if-none-match", "unsupported"); + Response response = resourceRequest(HttpMethod.PUT, "/folder/big", "1", "if-none-match", "unsupported"); verify(response, 400, "only header if-none-match=* is supported"); } @@ -195,28 +87,28 @@ void testRandom() { String notFound = "Not found: conversations/3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST" + path; if (type == 0) { - Response resource = request(HttpMethod.PUT, path, body); + Response resource = resourceRequest(HttpMethod.PUT, path, body); verifyNotExact(resource, 200, path); - resource = request(HttpMethod.GET, path); + resource = resourceRequest(HttpMethod.GET, path); verify(resource, 200, body); continue; } if (type == 1) { - Response response = request(HttpMethod.DELETE, path); + Response response = resourceRequest(HttpMethod.DELETE, path); verify(response, response.ok() ? 200 : 404, response.ok() ? "" : notFound); continue; } if (type == 2) { - Response response = request(HttpMethod.GET, path); + Response response = resourceRequest(HttpMethod.GET, path); if (response.status() == 200) { body = response.body() + body; - Response resource = request(HttpMethod.PUT, path, body); + Response resource = resourceRequest(HttpMethod.PUT, path, body); verifyNotExact(resource, 200, path); - resource = request(HttpMethod.GET, path); + resource = resourceRequest(HttpMethod.GET, path); verify(resource, 200, body); } else { verify(response, 404, notFound); @@ -227,66 +119,4 @@ void testRandom() { throw new IllegalStateException("Unreachable code"); } } - - private static void verify(Response response, int status, String body) { - assertEquals(status, response.status()); - assertEquals(body, response.body()); - } - - private static void verifyNotExact(Response response, int status, String part) { - assertEquals(status, response.status()); - if (!response.body.contains(part)) { - Assertions.fail("Body: " + response.body + ". Does not contains: " + part); - } - } - - private Response request(HttpMethod method, String resource) { - return request(method, resource, ""); - } - - private Response request(HttpMethod method, String resource, String body, String... headers) { - return send(method, "/v1/conversations/" + bucket + resource, body, headers); - } - - private Response metadata(String resource) { - return send(HttpMethod.GET, "/v1/metadata/conversations/" + bucket + resource, ""); - } - - @SneakyThrows - private Response send(HttpMethod method, String path, String body, String... headers) { - String uri = "http://127.0.0.1:" + dial.getServer().actualPort() + path; - HttpUriRequest request; - - if (method == HttpMethod.GET) { - request = new HttpGet(uri); - } else if (method == HttpMethod.PUT) { - HttpPut put = new HttpPut(uri); - put.setEntity(new StringEntity(body)); - request = put; - } else if (method == HttpMethod.DELETE) { - request = new HttpDelete(uri); - } else { - throw new IllegalArgumentException("Unsupported method: " + method); - } - - request.addHeader("api-key", "proxyKey1"); - - for (int i = 0; i < headers.length; i += 2) { - String key = headers[i]; - String value = headers[i + 1]; - request.addHeader(key, value); - } - - try (CloseableHttpResponse response = client.execute(request)) { - int status = response.getStatusLine().getStatusCode(); - String answer = EntityUtils.toString(response.getEntity()); - return new Response(status, answer); - } - } - - private record Response(int status, String body) { - public boolean ok() { - return status() == 200; - } - } } \ 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 new file mode 100644 index 000000000..246f73b6c --- /dev/null +++ b/src/test/java/com/epam/aidial/core/ResourceBaseTest.java @@ -0,0 +1,197 @@ +package com.epam.aidial.core; + +import com.epam.aidial.core.util.ProxyUtil; +import com.fasterxml.jackson.core.JsonProcessingException; +import io.vertx.core.http.HttpMethod; +import io.vertx.core.json.JsonObject; +import lombok.SneakyThrows; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpDelete; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.client.methods.HttpPut; +import org.apache.http.client.methods.HttpUriRequest; +import org.apache.http.entity.StringEntity; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClientBuilder; +import org.apache.http.util.EntityUtils; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import redis.embedded.RedisServer; + +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +public class ResourceBaseTest { + + RedisServer redis; + AiDial dial; + Path testDir; + CloseableHttpClient client; + String bucket; + + @BeforeEach + void init() throws Exception { + try { + testDir = FileUtil.baseTestPath(ResourceApiTest.class); + FileUtil.createDir(testDir.resolve("test")); + + redis = RedisServer.builder() + .port(16370) + .setting("bind 127.0.0.1") + .setting("maxmemory 16M") + .setting("maxmemory-policy volatile-lfu") + .build(); + redis.start(); + + client = HttpClientBuilder.create().build(); + + String overrides = """ + { + "client": { + "connectTimeout": 5000 + }, + "storage": { + "bucket": "test", + "provider": "filesystem", + "identity": "access-key", + "credential": "secret-key", + "overrides": { + "jclouds.filesystem.basedir": "%s" + } + }, + "redis": { + "singleServerConfig": { + "address": "redis://localhost:16370" + } + }, + "resources": { + "syncPeriod": 100, + "syncDelay": 100, + "cacheExpiration": 100 + } + } + """.formatted(testDir); + + JsonObject settings = AiDial.settings() + .mergeIn(new JsonObject(overrides), true); + + dial = new AiDial(); + dial.setSettings(settings); + dial.start(); + + Response response = send(HttpMethod.GET, "/v1/bucket", null, ""); + assertEquals(response.status, 200); + bucket = new JsonObject(response.body).getString("bucket"); + assertNotNull(bucket); + } catch (Throwable e) { + destroy(); + throw e; + } + } + + @AfterEach + void destroy() throws Exception { + try { + if (client != null) { + client.close(); + } + + if (dial != null) { + dial.stop(); + } + } finally { + if (redis != null) { + redis.stop(); + } + + FileUtil.deleteDir(testDir); + } + } + + static void verify(Response response, int status) { + assertEquals(status, response.status()); + } + + static void verify(Response response, int status, String body) { + assertEquals(status, response.status()); + assertEquals(body, response.body()); + } + + static void verifyJson(Response response, int status, String body) { + assertEquals(status, response.status()); + try { + assertEquals(ProxyUtil.MAPPER.readTree(body).toPrettyString(), ProxyUtil.MAPPER.readTree(response.body()).toPrettyString()); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } + + static void verifyNotExact(Response response, int status, String part) { + assertEquals(status, response.status()); + if (!response.body.contains(part)) { + Assertions.fail("Body: " + response.body + ". Does not contains: " + part); + } + } + + Response resourceRequest(HttpMethod method, String resource) { + return resourceRequest(method, resource, ""); + } + + Response resourceRequest(HttpMethod method, String resource, String body, String... headers) { + return send(method, "/v1/conversations/" + bucket + resource, null, body, headers); + } + + Response operationRequest(String path, String body, String... headers) { + return send(HttpMethod.POST, path, null, body, headers); + } + + Response metadata(String resource) { + return send(HttpMethod.GET, "/v1/metadata/conversations/" + bucket + resource, null, ""); + } + + @SneakyThrows + Response send(HttpMethod method, String path, String queryParams, String body, String... headers) { + String uri = "http://127.0.0.1:" + dial.getServer().actualPort() + path + (queryParams != null ? "?" + queryParams : ""); + HttpUriRequest request; + + if (method == HttpMethod.GET) { + request = new HttpGet(uri); + } else if (method == HttpMethod.PUT) { + HttpPut put = new HttpPut(uri); + put.setEntity(new StringEntity(body)); + request = put; + } else if (method == HttpMethod.DELETE) { + request = new HttpDelete(uri); + } else if (method == HttpMethod.POST) { + HttpPost post = new HttpPost(uri); + post.setEntity(new StringEntity(body)); + request = post; + } else { + throw new IllegalArgumentException("Unsupported method: " + method); + } + + request.setHeader("api-key", "proxyKey1"); + + for (int i = 0; i < headers.length; i += 2) { + String key = headers[i]; + String value = headers[i + 1]; + request.setHeader(key, value); + } + + try (CloseableHttpResponse response = client.execute(request)) { + int status = response.getStatusLine().getStatusCode(); + String answer = EntityUtils.toString(response.getEntity()); + return new Response(status, answer); + } + } + + record Response(int status, String body) { + public boolean ok() { + return status() == 200; + } + } +} diff --git a/src/test/java/com/epam/aidial/core/ShareApiTest.java b/src/test/java/com/epam/aidial/core/ShareApiTest.java new file mode 100644 index 000000000..c46c09fcc --- /dev/null +++ b/src/test/java/com/epam/aidial/core/ShareApiTest.java @@ -0,0 +1,496 @@ +package com.epam.aidial.core; + +import com.epam.aidial.core.data.InvitationLink; +import com.epam.aidial.core.util.ProxyUtil; +import io.vertx.core.http.HttpMethod; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertNotNull; + +public class ShareApiTest extends ResourceBaseTest { + + @Test + public void testShareWorkflow() { + // check no conversations shared with me + Response response = operationRequest("/v1/ops/resource/share/list", """ + { + "resourceTypes": ["CONVERSATION"], + "with": "me" + } + """); + verifyJson(response, 200, """ + { + "resources": [] + } + """); + + // check no conversations shared by me + response = operationRequest("/v1/ops/resource/share/list", """ + { + "resourceTypes": ["CONVERSATION"], + "with": "others" + } + """); + verifyJson(response, 200, """ + { + "resources": [] + } + """); + + // create conversation + response = resourceRequest(HttpMethod.PUT, "/folder/conversation%201", "12345"); + verifyNotExact(response, 200, "\"url\":\"conversations/3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST/folder/conversation%201\""); + + // initialize share request + response = operationRequest("/v1/ops/resource/share/create", """ + { + "invitationType": "link", + "resources": [ + { + "url": "conversations/3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST/folder/conversation%201" + } + ] + } + """); + 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%201\""); + + // verify user2 do not have access to the conversation + response = resourceRequest(HttpMethod.GET, "/folder/conversation%201", 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%201", null, "Api-key", "proxyKey2"); + verify(response, 200, "12345"); + + // 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"); + verifyJson(response, 200, """ + { + "resources" : [ { + "name" : "conversation 1", + "parentPath" : "folder", + "bucket" : "3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST", + "url" : "conversations/3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST/folder/conversation%201", + "nodeType" : "ITEM", + "resourceType" : "CONVERSATION" + } ] + } + """); + + // verify user1 has shared_by_me resource + response = operationRequest("/v1/ops/resource/share/list", """ + { + "resourceTypes": ["CONVERSATION"], + "with": "others" + } + """); + verifyJson(response, 200, """ + { + "resources" : [ { + "name" : "conversation 1", + "parentPath" : "folder", + "bucket" : "3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST", + "url" : "conversations/3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST/folder/conversation%201", + "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 testRevokeSharedAccess() { + // check no conversations shared with me + Response response = operationRequest("/v1/ops/resource/share/list", """ + { + "resourceTypes": ["CONVERSATION"], + "with": "me" + } + """); + verifyJson(response, 200, """ + { + "resources": [] + } + """); + + // check no conversations shared by me + response = operationRequest("/v1/ops/resource/share/list", """ + { + "resourceTypes": ["CONVERSATION"], + "with": "others" + } + """); + verifyJson(response, 200, """ + { + "resources": [] + } + """); + + // create conversation + response = resourceRequest(HttpMethod.PUT, "/folder/conversation", "12345"); + 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 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, "12345"); + + // revoke share access + response = operationRequest("/v1/ops/resource/share/revoke", """ + { + "resources": [ + { + "url": "conversations/3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST/folder/conversation" + } + ] + } + """); + verify(response, 200); + + // verify user2 do not have access to the conversation + response = resourceRequest(HttpMethod.GET, "/folder/conversation", null, "Api-key", "proxyKey2"); + verify(response, 403); + + // 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 no shared_with_me resource + response = operationRequest("/v1/ops/resource/share/list", """ + { + "resourceTypes": ["CONVERSATION"], + "with": "me" + } + """, "Api-key", "proxyKey2"); + verifyJson(response, 200, """ + { + "resources" : [] + } + """); + + // verify user1 has no shared_by_me resource + response = operationRequest("/v1/ops/resource/share/list", """ + { + "resourceTypes": ["CONVERSATION"], + "with": "others" + } + """); + verifyJson(response, 200, """ + { + "resources" : [] + } + """); + + // 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 testDiscardSharedAccess() { + // check no conversations shared with me + Response response = operationRequest("/v1/ops/resource/share/list", """ + { + "resourceTypes": ["CONVERSATION"], + "with": "me" + } + """); + verifyJson(response, 200, """ + { + "resources": [] + } + """); + + // check no conversations shared by me + response = operationRequest("/v1/ops/resource/share/list", """ + { + "resourceTypes": ["CONVERSATION"], + "with": "others" + } + """); + verifyJson(response, 200, """ + { + "resources": [] + } + """); + + // create conversation + response = resourceRequest(HttpMethod.PUT, "/folder/conversation", "12345"); + 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 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, "12345"); + + // discard share access + response = operationRequest("/v1/ops/resource/share/discard", """ + { + "resources": [ + { + "url": "conversations/3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST/folder/conversation" + } + ] + } + """, "Api-key", "proxyKey2"); + verify(response, 200); + + // verify user2 do not have access to the conversation + response = resourceRequest(HttpMethod.GET, "/folder/conversation", null, "Api-key", "proxyKey2"); + verify(response, 403); + + // 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 no shared_with_me resource + response = operationRequest("/v1/ops/resource/share/list", """ + { + "resourceTypes": ["CONVERSATION"], + "with": "me" + } + """, "Api-key", "proxyKey2"); + verifyJson(response, 200, """ + { + "resources" : [] + } + """); + + // verify user1 has shared_by_me resource + response = operationRequest("/v1/ops/resource/share/list", """ + { + "resourceTypes": ["CONVERSATION"], + "with": "others" + } + """); + verifyJson(response, 200, """ + { + "resources" : [ { + "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 testShareRequestWithIncorrectBody() { + Response response = operationRequest("/v1/ops/resource/share/create", """ + { + "invitationTypes": "link", + "resources": [ + "conversations/3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST/folder/conversation%201" + ] + } + """); + verify(response, 400, "Can't initiate share request. Incorrect body"); + } + + @Test + public void testIncorrectResourceLink() { + Response response = operationRequest("/v1/ops/resource/share/create", """ + { + "invitationType": "link", + "resources": [ + { + "url": "conversations/3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST/folder/conversation 1" + } + ] + } + """); + verify(response, 400, "Incorrect resource link provided conversations/3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST/folder/conversation 1"); + } + + @Test + public void testIncorrectResourceLink2() { + Response response = operationRequest("/v1/ops/resource/share/create", """ + { + "invitationType": "link", + "resources": [ + { + "url": "conversations/3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST/%2F" + } + ] + } + """); + verify(response, 400, "Incorrect resource link provided conversations/3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST/%2F"); + } + + @Test + public void testWrongResourceLink() { + // try to share resource where user is not an owner + Response response = operationRequest("/v1/ops/resource/share/create", """ + { + "invitationType": "link", + "resources": [ + { + "url": "conversations/7G9WZNcoY26Vy9D7bEgbv6zqbJGfyDp9KZyEbJR4XMZt/folder/conversation" + } + ] + } + """); + verify(response, 400, "Resource conversations/7G9WZNcoY26Vy9D7bEgbv6zqbJGfyDp9KZyEbJR4XMZt/folder/conversation does not belong to the user"); + } + + @Test + public void testShareEmptyResourceList() { + Response response = operationRequest("/v1/ops/resource/share/create", """ + { + "invitationType": "link", + "resources": [] + } + """); + verify(response, 400, "No resources provided"); + } + + @Test + public void testAcceptOwnShareRequest() { + // initialize share request + Response 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); + + // accept invitation + response = send(HttpMethod.GET, invitationLink.invitationLink(), "accept=true", null); + verify(response, 400, "Resource conversations/3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST/folder/conversation already belong to you"); + } +}