Skip to content

Commit

Permalink
Create initial S3 endpoint resource/handler
Browse files Browse the repository at this point in the history
Closes #8
  • Loading branch information
Randgalt committed May 16, 2024
1 parent ac858cb commit 56ad313
Show file tree
Hide file tree
Showing 11 changed files with 214 additions and 17 deletions.
7 changes: 7 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@

<dep.airlift.version>245</dep.airlift.version>
<dep.aws-sdk.version>2.25.32</dep.aws-sdk.version>
<dep.jersey.version>3.1.6</dep.jersey.version>
</properties>

<dependencyManagement>
Expand All @@ -66,6 +67,12 @@
<type>pom</type>
<scope>import</scope>
</dependency>

<dependency>
<groupId>org.glassfish.jersey.core</groupId>
<artifactId>jersey-server</artifactId>
<version>${dep.jersey.version}</version>
</dependency>
</dependencies>
</dependencyManagement>

Expand Down
5 changes: 5 additions & 0 deletions trino-s3-proxy/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,11 @@
<artifactId>jakarta.ws.rs-api</artifactId>
</dependency>

<dependency>
<groupId>org.glassfish.jersey.core</groupId>
<artifactId>jersey-server</artifactId>
</dependency>

<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,19 @@

import static java.util.Objects.requireNonNull;

public record Credentials(String emulatedAccessKey, String emulatedSecretKey)
public record Credentials(CredentialsEntry emulated)
{
public Credentials
{
requireNonNull(emulatedAccessKey, "emulatedAccessKey is null");
requireNonNull(emulatedSecretKey, "emulatedSecretKey is null");
requireNonNull(emulated, "emulated is null");
}

public record CredentialsEntry(String accessKey, String secretKey)
{
public CredentialsEntry
{
requireNonNull(accessKey, "accessKey is null");
requireNonNull(secretKey, "secretKey is null");
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,6 @@
public interface CredentialsController
{
Optional<Credentials> credentials(String emulatedAccessKey);

void upsertCredentials(Credentials credentials);
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
*/
package io.trino.s3.proxy.server;

import com.google.common.base.Splitter;
import com.google.inject.Inject;
import io.trino.s3.proxy.server.minio.Signer;
import io.trino.s3.proxy.server.minio.emulation.MinioRequest;
Expand All @@ -21,7 +22,9 @@

import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.List;
import java.util.Map;
import java.util.Optional;

import static java.util.Objects.requireNonNull;

Expand All @@ -35,10 +38,58 @@ public SigningController(CredentialsController credentialsController)
this.credentialsController = requireNonNull(credentialsController, "credentialsController is null");
}

public Map<String, String> signedRequestHeaders(String method, MultivaluedMap<String, String> requestHeaders, String encodedPath, String encodedQuery, String region, String accessKey)
public record Scope(String authorization, String accessKey, String region)
{
public Scope
{
authorization = requireNonNull(authorization, "accessKey is null");
accessKey = requireNonNull(accessKey, "accessKey is null");
region = requireNonNull(region, "region is null");
}

public static Optional<Scope> fromHeaders(MultivaluedMap<String, String> requestHeaders)
{
String authorization = requestHeaders.getFirst("Authorization");
if (authorization == null) {
return Optional.empty();
}

List<String> authorizationParts = Splitter.on(",").trimResults().splitToList(authorization);
if (authorizationParts.isEmpty()) {
return Optional.empty();
}

String credential = authorizationParts.getFirst();
List<String> credentialParts = Splitter.on("=").splitToList(credential);
if (credentialParts.size() < 2) {
return Optional.empty();
}

String credentialValue = credentialParts.get(1);
List<String> credentialValueParts = Splitter.on("/").splitToList(credentialValue);
if (credentialValueParts.size() < 3) {
return Optional.empty();
}

String accessKey = credentialValueParts.getFirst();
String region = credentialValueParts.get(2);
return Optional.of(new Scope(authorization, accessKey, region));
}
}

public boolean validateRequest(String method, MultivaluedMap<String, String> requestHeaders, String encodedPath, String encodedQuery)
{
return Scope.fromHeaders(requestHeaders).map(scope -> {
Map<String, String> signedRequestHeaders = signedRequestHeaders(scope, method, requestHeaders, encodedPath, encodedQuery);
String requestAuthorization = signedRequestHeaders.get("Authorization");
return scope.authorization.equals(requestAuthorization);
}).orElse(false);
}

public Map<String, String> signedRequestHeaders(Scope scope, String method, MultivaluedMap<String, String> requestHeaders, String encodedPath, String encodedQuery)
{
// TODO
Credentials credentials = credentialsController.credentials(accessKey).orElseThrow();
Credentials credentials = credentialsController.credentials(scope.accessKey).orElseThrow();

MinioUrl minioUrl = MinioUrl.build(encodedPath, encodedQuery);
MinioRequest minioRequest = MinioRequest.build(requestHeaders, method, minioUrl);
Expand All @@ -47,7 +98,7 @@ public Map<String, String> signedRequestHeaders(String method, MultivaluedMap<St
String sha256 = minioRequest.headerValue("x-amz-content-sha256").orElseThrow();

try {
return Signer.signV4S3(minioRequest, region, accessKey, credentials.emulatedSecretKey(), sha256).headers();
return Signer.signV4S3(minioRequest, scope.region, scope.accessKey, credentials.emulated().secretKey(), sha256).headers();
}
catch (NoSuchAlgorithmException | InvalidKeyException e) {
// TODO
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

import com.google.inject.Binder;
import com.google.inject.Module;
import com.google.inject.Scopes;
import io.trino.s3.proxy.server.rest.TrinoS3ProxyResource;

import static io.airlift.jaxrs.JaxrsBinder.jaxrsBinder;
Expand All @@ -26,5 +27,7 @@ public class TrinoS3ProxyServerModule
public void configure(Binder binder)
{
jaxrsBinder(binder).bind(TrinoS3ProxyResource.class);

binder.bind(SigningController.class).in(Scopes.SINGLETON);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,19 +13,78 @@
*/
package io.trino.s3.proxy.server.rest;

import com.google.inject.Inject;
import io.trino.s3.proxy.server.SigningController;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.HEAD;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import org.glassfish.jersey.server.ContainerRequest;

@Path(TrinoS3ProxyRestConstants.BASE_PATH)
import static com.google.common.base.MoreObjects.firstNonNull;
import static java.util.Objects.requireNonNull;

@Path(TrinoS3ProxyRestConstants.S3_PATH)
public class TrinoS3ProxyResource
{
private final SigningController signingController;

@Inject
public TrinoS3ProxyResource(SigningController signingController)
{
this.signingController = requireNonNull(signingController, "signingController is null");
}

@GET
@Path("hello")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public Response s3Get(@Context ContainerRequest request)
{
return s3Get(request, "");
}

@GET
@Path("{bucket:.*}")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public Response s3Get(@Context ContainerRequest request, @PathParam("bucket") String bucket)
{
validateRequest(request);
return Response.ok().build();
}

@HEAD
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public String hello()
public Response s3Head(@Context ContainerRequest request)
{
return "hello";
return s3Head(request, "");
}

@HEAD
@Path("{bucket}/{remainingPath:.*}")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public Response s3Head(@Context ContainerRequest request, @PathParam("bucket") String bucket)
{
validateRequest(request);
return Response.ok().build();
}

private void validateRequest(ContainerRequest request)
{
String encodedPath = "/" + firstNonNull(request.getPath(false), "");
String encodedQuery = firstNonNull(request.getUriInfo().getRequestUri().getRawQuery(), "");

if (!signingController.validateRequest(request.getMethod(), request.getRequestHeaders(), encodedPath, encodedQuery)) {
// TODO logging, etc.
throw new WebApplicationException(Response.Status.UNAUTHORIZED);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,5 @@ public final class TrinoS3ProxyRestConstants
private TrinoS3ProxyRestConstants() {}

public static final String BASE_PATH = "/api/v1/s3Proxy/";
public static final String S3_PATH = BASE_PATH + "s3";
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
*/
package io.trino.s3.proxy.server;

import io.trino.s3.proxy.server.Credentials.CredentialsEntry;
import io.trino.s3.proxy.server.SigningController.Scope;
import jakarta.ws.rs.core.MultivaluedHashMap;
import jakarta.ws.rs.core.MultivaluedMap;
import org.junit.jupiter.api.Test;
Expand All @@ -24,12 +26,26 @@

public class TestSigningController
{
private static final Credentials CREDENTIALS = new Credentials("THIS_IS_AN_ACCESS_KEY", "THIS_IS_A_SECRET_KEY");
private static final Credentials CREDENTIALS = new Credentials(new CredentialsEntry("THIS_IS_AN_ACCESS_KEY", "THIS_IS_A_SECRET_KEY"));

private final CredentialsController credentialsController = new CredentialsController()
{
@Override
public Optional<Credentials> credentials(String emulatedAccessKey)
{
return Optional.of(CREDENTIALS);
}

@Override
public void upsertCredentials(Credentials credentials)
{
throw new UnsupportedOperationException();
}
};

@Test
public void testRootLs()
{
CredentialsController credentialsController = accessKey -> Optional.of(CREDENTIALS);
SigningController signingController = new SigningController(credentialsController);

// values discovered from an AWS CLI request sent to a dummy local HTTP server
Expand All @@ -41,15 +57,14 @@ public void testRootLs()
requestHeaders.putSingle("User-Agent", "aws-cli/2.15.16 Python/3.11.7 Darwin/22.6.0 source/x86_64 prompt/off command/s3.ls");
requestHeaders.putSingle("Accept-Encoding", "identity");

Map<String, String> signedHeaders = signingController.signedRequestHeaders("GET", requestHeaders, "/", "", "us-east-1", "THIS_IS_AN_ACCESS_KEY");
Map<String, String> signedHeaders = signingController.signedRequestHeaders(new Scope("dummy", "THIS_IS_AN_ACCESS_KEY", "us-east-1"), "GET", requestHeaders, "/", "");

assertThat(signedHeaders).contains(Map.entry("Authorization", "AWS4-HMAC-SHA256 Credential=THIS_IS_AN_ACCESS_KEY/20240516/us-east-1/s3/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date;x-amz-security-token, Signature=9a19c251bf4e1533174e80da59fa57c65b3149b611ec9a4104f6944767c25704"));
}

@Test
public void testBucketLs()
{
CredentialsController credentialsController = accessKey -> Optional.of(CREDENTIALS);
SigningController signingController = new SigningController(credentialsController);

// values discovered from an AWS CLI request sent to a dummy local HTTP server
Expand All @@ -61,7 +76,7 @@ public void testBucketLs()
requestHeaders.putSingle("User-Agent", "aws-cli/2.15.16 Python/3.11.7 Darwin/22.6.0 source/x86_64 prompt/off command/s3.ls");
requestHeaders.putSingle("Accept-Encoding", "identity");

Map<String, String> signedHeaders = signingController.signedRequestHeaders("GET", requestHeaders, "/mybucket", "list-type=2&prefix=foo%2Fbar&delimiter=%2F&encoding-type=url", "us-east-1", "THIS_IS_AN_ACCESS_KEY");
Map<String, String> signedHeaders = signingController.signedRequestHeaders(new Scope("dummy", "THIS_IS_AN_ACCESS_KEY", "us-east-1"), "GET", requestHeaders, "/mybucket", "list-type=2&prefix=foo%2Fbar&delimiter=%2F&encoding-type=url");

assertThat(signedHeaders).contains(Map.entry("Authorization", "AWS4-HMAC-SHA256 Credential=THIS_IS_AN_ACCESS_KEY/20240516/us-east-1/s3/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date;x-amz-security-token, Signature=222d7b7fcd4d5560c944e8fecd9424ee3915d131c3ad9e000d65db93e87946c4"));
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License 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 io.trino.s3.proxy.server;

import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;

public class TestingCredentialsController
implements CredentialsController
{
private final Map<String, Credentials> credentials = new ConcurrentHashMap<>();

@Override
public Optional<Credentials> credentials(String emulatedAccessKey)
{
return Optional.ofNullable(credentials.get(emulatedAccessKey));
}

@Override
public void upsertCredentials(Credentials credentials)
{
this.credentials.put(credentials.emulated().accessKey(), credentials);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,17 @@
package io.trino.s3.proxy.server;

import com.google.common.collect.ImmutableList;
import com.google.inject.Injector;
import com.google.inject.Module;
import com.google.inject.Scopes;
import io.airlift.bootstrap.Bootstrap;
import io.airlift.event.client.EventModule;
import io.airlift.http.server.testing.TestingHttpServerModule;
import io.airlift.jaxrs.JaxrsModule;
import io.airlift.json.JsonModule;
import io.airlift.log.Logger;
import io.airlift.node.testing.TestingNodeModule;
import io.trino.s3.proxy.server.Credentials.CredentialsEntry;

public final class TestingTrinoS3ProxyServer
{
Expand All @@ -37,10 +40,17 @@ public static void main(String[] args)
.add(new EventModule())
.add(new TestingHttpServerModule())
.add(new JsonModule())
.add(new JaxrsModule());
.add(new JaxrsModule())
.add(binder -> {
binder.bind(CredentialsController.class).to(TestingCredentialsController.class).in(Scopes.SINGLETON);
binder.bind(TestingCredentialsController.class).in(Scopes.SINGLETON);
});

Bootstrap app = new Bootstrap(modules.build());
app.initialize();
Injector injector = app.initialize();

TestingCredentialsController credentialsController = injector.getInstance(TestingCredentialsController.class);
credentialsController.upsertCredentials(new Credentials(new CredentialsEntry("THIS_IS_AN_ACCESS_KEY", "THIS_IS_A_SECRET_KEY")));

log.info("======== TESTING SERVER STARTED ========");
}
Expand Down

0 comments on commit 56ad313

Please sign in to comment.