diff --git a/src/main/java/software/amazon/awssdk/s3accessgrants/plugin/internal/S3AccessGrantsUtils.java b/src/main/java/software/amazon/awssdk/s3accessgrants/plugin/internal/S3AccessGrantsUtils.java index 165594a..4166416 100644 --- a/src/main/java/software/amazon/awssdk/s3accessgrants/plugin/internal/S3AccessGrantsUtils.java +++ b/src/main/java/software/amazon/awssdk/s3accessgrants/plugin/internal/S3AccessGrantsUtils.java @@ -22,6 +22,13 @@ import software.amazon.awssdk.utils.Logger; import software.amazon.awssdk.utils.Validate; +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedList; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + /** * The class is for defining all the utilities and constants to be used across the package * */ @@ -55,4 +62,63 @@ public static void argumentNotNull(Object param, String message) { } } + /** + * Gets the Lowest Common Ancestor (LCA) for all S3 resources defined. + * This allows us to combine credentials to access paths like "s3://a/b/c" and "s3://a/b/d" + * using a singular set of credentials (which may be required for calls like CopyObject or + * DeleteObjects). In this case, the LCA of the example would be "s3://a/b" and we would use + * that as our final resource to query S3AG. + * @param s3Resources List of S3Resources we would like to find the LCA for + * @return A singular S3 path that is the LCA of all the inputted resources. + */ + public static String getLowestCommonAncestorPath(Collection s3Resources) { + Set allBuckets = + s3Resources.stream().map(S3AccessGrantsUtils::getBucketName).collect(Collectors.toSet()); + // If not all resources have the same bucket, we cannot have a common ancestor + if (allBuckets.size() > 1) { + String e = "[Finding LCA] LCA not possible due to multiple buckets found: " + allBuckets; + logger.debug(() -> e); + throw SdkServiceException.builder().message(e).build(); + } + List allPathPrefixes = + s3Resources.stream().map(S3AccessGrantsUtils::getPrefix).collect(Collectors.toList()); + LinkedList pathSoFar = new LinkedList<>(); + // Find the directory split by "/" and iterate through all splits until + // we find one that doesn't apply to all resources + String[] firstPathSplit = allPathPrefixes.get(0).split("/"); + logger.debug(() -> "[Finding LCA] All resources: " + allPathPrefixes); + for (String fragment : firstPathSplit) { + pathSoFar.addLast(fragment); + String currentAncestor = String.join("/", pathSoFar); + logger.debug(() -> "[Finding LCA] Checking prefix " + currentAncestor); + if (!allPathPrefixes.stream().allMatch(resource -> resource.startsWith(currentAncestor))) { + pathSoFar.removeLast(); + break; + } + } + // Add the S3 bucket to the path and join the result + pathSoFar.addFirst("s3://" + allBuckets.stream().findFirst().get()); + return String.join("/", pathSoFar); + } + + /** + * Get the S3 Bucket Name from a S3 Path. For example, the S3 path + * s3://a-bucket/b/c/d.txt would resolve to `a-bucket`. + * @param s3Resource String of s3 path we would like to find the S3 bucket name for + * @return A String containing the extracted bucket name + */ + private static String getBucketName(String s3Resource) { + return s3Resource.substring(5).split("/")[0]; + } + + /** + * Get the S3 Prefix from a S3 Path. For example, the S3 path + * s3://a-bucket/b/c/d.txt would resolve to `b/c/d.txt`. + * @param s3Resource String of s3 path we would like to find the S3 prefix for + * @return A String containing the extracted prefix + */ + private static String getPrefix(String s3Resource) { + return s3Resource.substring(5).split("/", 2)[1]; + } + } diff --git a/src/test/java/software/amazon/awssdk/s3accessgrants/plugin/S3AccessGrantsUtilsTests.java b/src/test/java/software/amazon/awssdk/s3accessgrants/plugin/S3AccessGrantsUtilsTests.java new file mode 100644 index 0000000..1ce0a00 --- /dev/null +++ b/src/test/java/software/amazon/awssdk/s3accessgrants/plugin/S3AccessGrantsUtilsTests.java @@ -0,0 +1,79 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.awssdk.s3accessgrants.plugin; + +import org.assertj.core.api.Assertions; +import org.junit.Assert; +import org.junit.Test; +import software.amazon.awssdk.core.exception.SdkServiceException; +import software.amazon.awssdk.s3accessgrants.plugin.internal.S3AccessGrantsUtils; + +import java.util.ArrayList; + +public class S3AccessGrantsUtilsTests { + @Test + public void test_lowest_common_ancestor_when_ancestor_exists() { + ArrayList paths = new ArrayList(); + paths.add("s3://test-bucket/path1/path2/path3/some.txt"); + paths.add("s3://test-bucket/path1/path2/someother.txt"); + paths.add("s3://test-bucket/path1/path2/path99/more.txt"); + paths.add("s3://test-bucket/path1/path2/"); + + Assert.assertEquals("s3://test-bucket/path1/path2", S3AccessGrantsUtils.getLowestCommonAncestorPath(paths)); + } + + @Test + public void test_lowest_common_ancestor_when_ancestor_does_not_exist() { + ArrayList paths = new ArrayList(); + paths.add("s3://test-bucket/path1/path2/path3/some.txt"); + paths.add("s3://test-bucket/path1/path2/someother.txt"); + paths.add("s3://random-bucket/path1/path2/path99/more.txt"); + paths.add("s3://test-bucket/path1/path2/"); + + Assertions.assertThatThrownBy(()->S3AccessGrantsUtils.getLowestCommonAncestorPath(paths)).isInstanceOf(SdkServiceException.class); + } + + @Test + public void test_lowest_common_ancestor_when_ancestor_is_only_bucket() { + ArrayList paths = new ArrayList(); + paths.add("s3://test-bucket/path1/path2/path3/some.txt"); + paths.add("s3://test-bucket/path1/path2/someother.txt"); + paths.add("s3://test-bucket/path98/path99/more.txt"); + paths.add("s3://test-bucket/path1/path2/"); + + Assert.assertEquals("s3://test-bucket", S3AccessGrantsUtils.getLowestCommonAncestorPath(paths)); + } + + @Test + public void test_lowest_common_ancestor_when_ancestor_is_full_path() { + String path = "s3://test-bucket/path1/path2/path3/some.txt"; + ArrayList paths = new ArrayList(); + for (int i = 0; i < 10; i++) { + paths.add(path); + } + + Assert.assertEquals(path, S3AccessGrantsUtils.getLowestCommonAncestorPath(paths)); + } + + @Test + public void test_lowest_common_ancestor_when_singular_path() { + String path = "s3://test-bucket/path1/path2/path3/some.txt"; + ArrayList paths = new ArrayList(); + paths.add(path); + + Assert.assertEquals(path, S3AccessGrantsUtils.getLowestCommonAncestorPath(paths)); + } +}