diff options
author | Martin Polden <mpolden@mpolden.no> | 2018-02-21 11:31:18 +0100 |
---|---|---|
committer | GitHub <noreply@github.com> | 2018-02-21 11:31:18 +0100 |
commit | 15e8b4456a6b57ea8b144d175641b6032a0be267 (patch) | |
tree | 1ba8f9805257718f8dcbc81efbc257918b2cc6dd /node-repository | |
parent | 59e254a7d8ea61ddfb2adde30b0aecef70913704 (diff) | |
parent | 3564bc128ce89573799419a31b8736332eeb432e (diff) |
Merge pull request #5080 from vespa-engine/mpolden/node-repo-auth-filter
Authorization filter for node repository
Diffstat (limited to 'node-repository')
8 files changed, 520 insertions, 29 deletions
diff --git a/node-repository/pom.xml b/node-repository/pom.xml index 6673ea487a1..0959b5cc5b8 100644 --- a/node-repository/pom.xml +++ b/node-repository/pom.xml @@ -17,6 +17,7 @@ <description>Keeps track of node assignment in a multi-application setup.</description> <dependencies> + <!-- provided --> <dependency> <groupId>com.yahoo.vespa</groupId> <artifactId>container-dev</artifactId> @@ -25,12 +26,6 @@ </dependency> <dependency> <groupId>com.yahoo.vespa</groupId> - <artifactId>application</artifactId> - <version>${project.version}</version> - <scope>test</scope> - </dependency> - <dependency> - <groupId>com.yahoo.vespa</groupId> <artifactId>vespa_jersey2</artifactId> <version>${project.version}</version> <scope>provided</scope> @@ -69,7 +64,21 @@ <dependency> <groupId>com.google.guava</groupId> <artifactId>guava</artifactId> - </dependency> + <scope>provided</scope> + </dependency> + <dependency> + <groupId>com.fasterxml.jackson.core</groupId> + <artifactId>jackson-databind</artifactId> + <scope>provided</scope> + </dependency> + <dependency> + <groupId>com.yahoo.vespa</groupId> + <artifactId>orchestrator</artifactId> + <version>${project.version}</version> + <scope>provided</scope> + </dependency> + + <!-- compile --> <dependency> <groupId>com.fasterxml.jackson.datatype</groupId> <artifactId>jackson-datatype-joda</artifactId> @@ -86,17 +95,21 @@ </exclusions> </dependency> <dependency> - <groupId>com.fasterxml.jackson.core</groupId> - <artifactId>jackson-databind</artifactId> - <scope>provided</scope> + <groupId>org.bouncycastle</groupId> + <artifactId>bcpkix-jdk15on</artifactId> </dependency> <dependency> + <groupId>org.apache.httpcomponents</groupId> + <artifactId>httpclient</artifactId> + </dependency> + + <!-- test --> + <dependency> <groupId>com.yahoo.vespa</groupId> - <artifactId>orchestrator</artifactId> + <artifactId>application</artifactId> <version>${project.version}</version> - <scope>provided</scope> + <scope>test</scope> </dependency> - <dependency> <groupId>com.yahoo.vespa</groupId> <artifactId>testutil</artifactId> @@ -129,14 +142,14 @@ <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> - <configuration> - <compilerArgs> - <arg>-Xlint:all</arg> - <arg>-Xlint:-try</arg> - <arg>-Xlint:-serial</arg> - <arg>-Werror</arg> - </compilerArgs> - </configuration> + <configuration> + <compilerArgs> + <arg>-Xlint:all</arg> + <arg>-Xlint:-try</arg> + <arg>-Xlint:-serial</arg> + <arg>-Werror</arg> + </compilerArgs> + </configuration> </plugin> </plugins> </build> diff --git a/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/restapi/v2/Authorizer.java b/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/restapi/v2/Authorizer.java new file mode 100644 index 00000000000..3810ca13195 --- /dev/null +++ b/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/restapi/v2/Authorizer.java @@ -0,0 +1,113 @@ +// Copyright 2018 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.hosted.provision.restapi.v2; + +import com.yahoo.config.provision.SystemName; +import com.yahoo.vespa.hosted.provision.Node; +import com.yahoo.vespa.hosted.provision.NodeRepository; +import org.apache.http.NameValuePair; +import org.apache.http.client.utils.URLEncodedUtils; + +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.nio.file.Paths; +import java.security.Principal; +import java.util.List; +import java.util.function.BiPredicate; +import java.util.stream.Collectors; + +/** + * An authorizer for the node-repository REST API. This contains the authorization rules for all API paths in this + * module. + * + * @author mpolden + */ +public class Authorizer implements BiPredicate<Principal, URI> { + + private final SystemName system; + private final NodeRepository nodeRepository; + + public Authorizer(SystemName system, NodeRepository nodeRepository) { + this.system = system; + this.nodeRepository = nodeRepository; + } + + /** Returns whether principal is authorized to access given URI */ + @Override + public boolean test(Principal principal, URI uri) { + // Trusted services can access everything + if (principal.getName().equals(trustedService())) { + return true; + } + + // Nodes can only access its own resources + if (isNodeResource(uri) && canAccess(hostnameFrom(uri), principal)) { + return true; + } + + // For resources that support filtering, nodes can only apply filter to themselves and their children + if (supportsFiltering(uri) && canAccess(hostnamesFrom(uri), principal)) { + return true; + } + + return false; + } + + /** Returns whether principal can access node identified by hostname */ + private boolean canAccess(String hostname, Principal principal) { + // Node can always access itself + if (principal.getName().equals(hostname)) { + return true; + } + // Parent node can access its children + return nodeRepository.getNode(hostname) + .flatMap(Node::parentHostname) + .map(parentHostname -> principal.getName().equals(parentHostname)) + .orElse(false); + } + + /** Returns whehter principal can access all nodes identified by given hostnames */ + private boolean canAccess(List<String> hostnames, Principal principal) { + return !hostnames.isEmpty() && hostnames.stream().allMatch(hostname -> canAccess(hostname, principal)); + } + + /** Trusted service name for this system */ + private String trustedService() { + if (system != SystemName.main) { + return "vespa.vespa." + system.name() + ".hosting"; + } + return "vespa.vespa.hosting"; + } + + /** Returns the last element (basename) of given path */ + private static String hostnameFrom(URI uri) { + return Paths.get(uri.getPath()).getFileName().toString(); + } + + /** Returns hostnames contained in query parameters of given URI */ + private static List<String> hostnamesFrom(URI uri) { + return URLEncodedUtils.parse(uri, StandardCharsets.UTF_8.name()) + .stream() + .filter(pair -> "hostname".equals(pair.getName())) + .map(NameValuePair::getValue) + .filter(hostname -> !hostname.isEmpty()) + .collect(Collectors.toList()); + } + + /** Returns whether given URI is a node-specific resource, e.g. /nodes/v2/node/node1.fqdn */ + private static boolean isNodeResource(URI uri) { + return isChildOf("/nodes/v2/acl/", uri.getPath()) || + isChildOf("/nodes/v2/node/", uri.getPath()) || + isChildOf("/nodes/v2/state/", uri.getPath()); + } + + /** Returns whether given path supports filtering through query parameters */ + private static boolean supportsFiltering(URI uri) { + return isChildOf("/nodes/v2/command/", uri.getPath()); + } + + /** Returns whether child is a sub-path of parent */ + private static boolean isChildOf(String parent, String child) { + return child.startsWith(parent) && child.length() > parent.length(); + } + +} diff --git a/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/restapi/v2/ErrorResponse.java b/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/restapi/v2/ErrorResponse.java index 6bbf89a906e..5d7d720a1e2 100644 --- a/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/restapi/v2/ErrorResponse.java +++ b/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/restapi/v2/ErrorResponse.java @@ -19,35 +19,49 @@ import static com.yahoo.jdisc.Response.Status.*; public class ErrorResponse extends HttpResponse { private final Slime slime = new Slime(); + private final String message; - public enum errorCodes { + public enum ErrorCode { + FORBIDDEN, + UNAUTHORIZED, NOT_FOUND, BAD_REQUEST, METHOD_NOT_ALLOWED, INTERNAL_SERVER_ERROR } - public ErrorResponse(int code, String errorType, String message) { + private ErrorResponse(int code, ErrorCode errorCode, String message) { super(code); + this.message = message; Cursor root = slime.setObject(); - root.setString("error-code", errorType); + root.setString("error-code", errorCode.name()); root.setString("message", message); } + public String message() { return message; } + public static ErrorResponse notFoundError(String message) { - return new ErrorResponse(NOT_FOUND, errorCodes.NOT_FOUND.name(), message); + return new ErrorResponse(NOT_FOUND, ErrorCode.NOT_FOUND, message); } public static ErrorResponse internalServerError(String message) { - return new ErrorResponse(INTERNAL_SERVER_ERROR, errorCodes.INTERNAL_SERVER_ERROR.name(), message); + return new ErrorResponse(INTERNAL_SERVER_ERROR, ErrorCode.INTERNAL_SERVER_ERROR, message); } public static ErrorResponse badRequest(String message) { - return new ErrorResponse(BAD_REQUEST, errorCodes.BAD_REQUEST.name(), message); + return new ErrorResponse(BAD_REQUEST, ErrorCode.BAD_REQUEST, message); } public static ErrorResponse methodNotAllowed(String message) { - return new ErrorResponse(METHOD_NOT_ALLOWED, errorCodes.METHOD_NOT_ALLOWED.name(), message); + return new ErrorResponse(METHOD_NOT_ALLOWED, ErrorCode.METHOD_NOT_ALLOWED, message); + } + + public static ErrorResponse unauthorized(String message) { + return new ErrorResponse(UNAUTHORIZED, ErrorCode.UNAUTHORIZED, message); + } + + public static ErrorResponse forbidden(String message) { + return new ErrorResponse(FORBIDDEN, ErrorCode.FORBIDDEN, message); } @Override diff --git a/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/restapi/v2/filter/AuthorizationFilter.java b/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/restapi/v2/filter/AuthorizationFilter.java new file mode 100644 index 00000000000..a54b6cd135c --- /dev/null +++ b/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/restapi/v2/filter/AuthorizationFilter.java @@ -0,0 +1,112 @@ +// Copyright 2018 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.hosted.provision.restapi.v2.filter; + +import com.google.inject.Inject; +import com.yahoo.config.provision.Zone; +import com.yahoo.jdisc.handler.FastContentWriter; +import com.yahoo.jdisc.handler.ResponseDispatch; +import com.yahoo.jdisc.handler.ResponseHandler; +import com.yahoo.jdisc.http.filter.DiscFilterRequest; +import com.yahoo.jdisc.http.filter.SecurityRequestFilter; +import com.yahoo.jdisc.http.servlet.ServletRequest; +import com.yahoo.vespa.hosted.provision.NodeRepository; +import com.yahoo.vespa.hosted.provision.restapi.v2.Authorizer; +import com.yahoo.vespa.hosted.provision.restapi.v2.ErrorResponse; +import org.bouncycastle.asn1.x500.RDN; +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x500.style.BCStyle; +import org.bouncycastle.asn1.x500.style.IETFUtils; +import org.bouncycastle.cert.jcajce.JcaX509CertificateHolder; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.net.URI; +import java.security.Principal; +import java.security.cert.CertificateEncodingException; +import java.security.cert.X509Certificate; +import java.util.Optional; +import java.util.function.BiConsumer; +import java.util.function.BiPredicate; +import java.util.logging.Logger; + +/** + * @author mpolden + */ +public class AuthorizationFilter implements SecurityRequestFilter { + + private static final Logger log = Logger.getLogger(AuthorizationFilter.class.getName()); + + private final BiPredicate<Principal, URI> authorizer; + private final BiConsumer<ErrorResponse, ResponseHandler> responseWriter; + + @Inject + public AuthorizationFilter(Zone zone, NodeRepository nodeRepository) { + this(new Authorizer(zone.system(), nodeRepository), AuthorizationFilter::log); // TODO: Use write method once all clients are using certificates + } + + AuthorizationFilter(BiPredicate<Principal, URI> authorizer, + BiConsumer<ErrorResponse, ResponseHandler> responseWriter) { + this.authorizer = authorizer; + this.responseWriter = responseWriter; + } + + @Override + public void filter(DiscFilterRequest request, ResponseHandler handler) { + Optional<X509Certificate> cert = certificateFrom(request); + if (cert.isPresent()) { + if (!authorizer.test(() -> commonName(cert.get()), request.getUri())) { + responseWriter.accept(ErrorResponse.forbidden( + String.format("%s %s denied for %s: Invalid credentials", request.getMethod(), + request.getUri().getPath(), request.getRemoteAddr())), handler + ); + } + } else { + responseWriter.accept(ErrorResponse.unauthorized( + String.format("%s %s denied for %s: Missing credentials", request.getMethod(), + request.getUri().getPath(), request.getRemoteAddr())), handler + ); + } + } + + /** Write error response */ + static void write(ErrorResponse response, ResponseHandler handler) { + try (FastContentWriter writer = ResponseDispatch.newInstance(response.getJdiscResponse()) + .connectFastWriter(handler)) { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + try { + response.render(out); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + writer.write(out.toByteArray()); + } + } + + /** Log error response without writing anything */ + private static void log(ErrorResponse response, @SuppressWarnings("unused") ResponseHandler handler) { + log.warning("Would reject request: " + response.getStatus() + " - " + response.message()); + } + + /** Read common name (CN) from certificate */ + private static String commonName(X509Certificate certificate) { + try { + X500Name subject = new JcaX509CertificateHolder(certificate).getSubject(); + RDN cn = subject.getRDNs(BCStyle.CN)[0]; + return IETFUtils.valueToString(cn.getFirst().getValue()); + } catch (CertificateEncodingException e) { + throw new RuntimeException(e); + } + } + + /** Get client certificate from request */ + private static Optional<X509Certificate> certificateFrom(DiscFilterRequest request) { + Object x509cert = request.getAttribute(ServletRequest.JDISC_REQUEST_X509CERT); + return Optional.ofNullable(x509cert) + .filter(X509Certificate[].class::isInstance) + .map(X509Certificate[].class::cast) + .filter(certs -> certs.length > 0) + .map(certs -> certs[0]); + } + +} diff --git a/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/restapi/v2/filter/package-info.java b/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/restapi/v2/filter/package-info.java new file mode 100644 index 00000000000..0e68629ef5e --- /dev/null +++ b/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/restapi/v2/filter/package-info.java @@ -0,0 +1,5 @@ +// Copyright 2018 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +/** + * @author mpolden + */ +package com.yahoo.vespa.hosted.provision.restapi.v2.filter; diff --git a/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/testutils/MockNodeRepository.java b/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/testutils/MockNodeRepository.java index 3b224fe2ba3..96916671888 100644 --- a/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/testutils/MockNodeRepository.java +++ b/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/testutils/MockNodeRepository.java @@ -43,7 +43,7 @@ public class MockNodeRepository extends NodeRepository { * Constructor * @param flavors flavors to have in node repo */ - public MockNodeRepository(MockCurator curator, NodeFlavors flavors) throws Exception { + public MockNodeRepository(MockCurator curator, NodeFlavors flavors) { super(flavors, curator, Clock.fixed(Instant.ofEpochMilli(123), ZoneId.of("Z")), Zone.defaultZone(), new MockNameResolver() .addRecord("test-container-1", "::2") diff --git a/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/restapi/v2/AuthorizerTest.java b/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/restapi/v2/AuthorizerTest.java new file mode 100644 index 00000000000..3a616b8adc3 --- /dev/null +++ b/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/restapi/v2/AuthorizerTest.java @@ -0,0 +1,78 @@ +// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.hosted.provision.restapi.v2; + +import com.yahoo.config.provision.SystemName; +import com.yahoo.vespa.curator.mock.MockCurator; +import com.yahoo.vespa.hosted.provision.testutils.MockNodeFlavors; +import com.yahoo.vespa.hosted.provision.testutils.MockNodeRepository; +import org.junit.Before; +import org.junit.Test; + +import java.net.URI; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * @author mpolden + */ +public class AuthorizerTest { + + private Authorizer authorizer; + private MockNodeRepository nodeRepository; + + @Before + public void before() { + nodeRepository = new MockNodeRepository(new MockCurator(), new MockNodeFlavors()); + authorizer = new Authorizer(SystemName.main, nodeRepository); + } + + @Test + public void authorization() { + // Empty principal + assertFalse(authorized("", "")); + assertFalse(authorized("", "/")); + + // Node can only access its own resources + assertFalse(authorized("node1", "")); + assertFalse(authorized("node1", "/")); + assertFalse(authorized("node1", "/nodes/v2/node")); + assertFalse(authorized("node1", "/nodes/v2/node/")); + assertFalse(authorized("node1", "/nodes/v2/node/node2")); + assertFalse(authorized("node1", "/nodes/v2/state/dirty/")); + assertFalse(authorized("node1", "/nodes/v2/state/dirty/node2")); + assertFalse(authorized("node1", "/nodes/v2/acl/node2")); + // Node resource always takes precedence over filter + assertFalse(authorized("node1", "/nodes/v2/acl/node2?hostname=node1")); + assertFalse(authorized("node1", "/nodes/v2/command/reboot/")); + assertFalse(authorized("node1", "/nodes/v2/command/reboot/?hostname=")); + assertFalse(authorized("node1", "/nodes/v2/command/reboot/?hostname=node2")); + assertTrue(authorized("node1", "/nodes/v2/node/node1")); + assertTrue(authorized("node1", "/nodes/v2/state/dirty/node1")); + assertTrue(authorized("node1", "/nodes/v2/acl/node1")); + assertTrue(authorized("node1", "/nodes/v2/command/reboot?hostname=node1")); + + // Host node can access itself and its children + assertFalse(authorized("dockerhost1.yahoo.com", "/nodes/v2/node/host5.yahoo.com")); + assertFalse(authorized("dockerhost1.yahoo.com", "/nodes/v2/command/reboot?hostname=host5.yahoo.com")); + assertTrue(authorized("dockerhost1.yahoo.com", "/nodes/v2/node/dockerhost1.yahoo.com")); + assertTrue(authorized("dockerhost1.yahoo.com", "/nodes/v2/node/host4.yahoo.com")); + assertTrue(authorized("dockerhost1.yahoo.com", "/nodes/v2/command/reboot?hostname=host4.yahoo.com")); + + // Trusted services can access everything in their own system + assertFalse(authorized("vespa.vespa.cd.hosting", "/")); // Wrong system + assertTrue(new Authorizer(SystemName.cd, nodeRepository).test(() -> "vespa.vespa.cd.hosting", uri("/"))); + assertTrue(authorized("vespa.vespa.hosting", "/")); + assertTrue(authorized("vespa.vespa.hosting", "/nodes/v2/node/")); + assertTrue(authorized("vespa.vespa.hosting", "/nodes/v2/node/node1")); + } + + private boolean authorized(String principal, String path) { + return authorizer.test(() -> principal, uri(path)); + } + + private static URI uri(String path) { + return URI.create("http://localhost").resolve(path); + } + +} diff --git a/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/restapi/v2/filter/AuthorizationFilterTest.java b/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/restapi/v2/filter/AuthorizationFilterTest.java new file mode 100644 index 00000000000..4c80f4bc257 --- /dev/null +++ b/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/restapi/v2/filter/AuthorizationFilterTest.java @@ -0,0 +1,156 @@ +// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.hosted.provision.restapi.v2.filter; + +import com.yahoo.application.container.handler.Request; +import com.yahoo.config.provision.SystemName; +import com.yahoo.container.jdisc.RequestHandlerTestDriver.MockResponseHandler; +import com.yahoo.jdisc.http.filter.DiscFilterRequest; +import com.yahoo.jdisc.http.servlet.ServletRequest; +import com.yahoo.vespa.curator.mock.MockCurator; +import com.yahoo.vespa.hosted.provision.restapi.v2.Authorizer; +import com.yahoo.vespa.hosted.provision.testutils.MockNodeFlavors; +import com.yahoo.vespa.hosted.provision.testutils.MockNodeRepository; +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x509.BasicConstraints; +import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.cert.X509v3CertificateBuilder; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.bouncycastle.operator.ContentSigner; +import org.bouncycastle.operator.OperatorCreationException; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; +import org.junit.Before; +import org.junit.Test; + +import java.io.IOException; +import java.math.BigInteger; +import java.net.URI; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.NoSuchAlgorithmException; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.time.Duration; +import java.time.Instant; +import java.util.Date; +import java.util.Optional; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * @author mpolden + */ +public class AuthorizationFilterTest { + + private AuthorizationFilter filter; + + @Before + public void before() { + filter = new AuthorizationFilter(new Authorizer(SystemName.main, new MockNodeRepository(new MockCurator(), + new MockNodeFlavors())), + AuthorizationFilter::write); + } + + @Test + public void filter() { + // These are just rudimentary tests of the filter. See AuthorizerTest for more exhaustive tests + Optional<Response> response = invokeFilter(request(Request.Method.GET, "/")); + assertResponse(401, "{\"error-code\":\"UNAUTHORIZED\",\"message\":\"GET / denied for " + + "unit-test: Missing credentials\"}", response); + + response = invokeFilter(request(Request.Method.GET, "/", "foo")); + assertResponse(403, "{\"error-code\":\"FORBIDDEN\",\"message\":\"GET / " + + "denied for unit-test: Invalid credentials\"}", response); + + response = invokeFilter(request(Request.Method.GET, "/nodes/v2/node/foo", "bar")); + assertResponse(403, "{\"error-code\":\"FORBIDDEN\",\"message\":\"GET /nodes/v2/node/foo " + + "denied for unit-test: Invalid credentials\"}", response); + + response = invokeFilter(request(Request.Method.GET, "/nodes/v2/node/foo", "foo")); + assertSuccess(response); + } + + private Optional<Response> invokeFilter(DiscFilterRequest request) { + MockResponseHandler handler = new MockResponseHandler(); + filter.filter(request, handler); + return Optional.ofNullable(handler.getResponse()) + .map(response -> new Response(response.getStatus(), handler.readAll())); + } + + private static DiscFilterRequest request(Request.Method method, String path) { + return request(method, path, null); + } + + private static DiscFilterRequest request(Request.Method method, String path, String commonName) { + DiscFilterRequest request = mock(DiscFilterRequest.class); + when(request.getMethod()).thenReturn(method.name()); + when(request.getUri()).thenReturn(URI.create("http://localhost").resolve(path)); + when(request.getRemoteAddr()).thenReturn("unit-test"); + if (commonName != null) { + X509Certificate cert = certificateFor(commonName, keyPair()); + when(request.getAttribute(ServletRequest.JDISC_REQUEST_X509CERT)) + .thenReturn(new X509Certificate[]{cert}); + } + return request; + } + + private static void assertSuccess(Optional<Response> response) { + assertFalse("No error in response", response.isPresent()); + } + + private static void assertResponse(int status, String body, Optional<Response> response) { + assertTrue("Expected response from filter", response.isPresent()); + assertEquals("Response body", body, response.get().body); + assertEquals("Status code", status, response.get().status); + } + + /** Create a RSA public/private key pair */ + private static KeyPair keyPair() { + try { + KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA"); + keyGen.initialize(2048); + return keyGen.generateKeyPair(); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException(e); + } + } + + /** Create a self signed certificate for commonName using given public/private key pair */ + private static X509Certificate certificateFor(String commonName, KeyPair keyPair) { + try { + ContentSigner contentSigner = new JcaContentSignerBuilder("SHA256WithRSA") + .build(keyPair.getPrivate()); + X500Name x500Name = new X500Name("CN=" + commonName); + Instant now = Instant.now(); + Date notBefore = Date.from(now); + Date notAfter = Date.from(now.plus(Duration.ofDays(30))); + X509v3CertificateBuilder certificateBuilder = + new JcaX509v3CertificateBuilder( + x500Name, + BigInteger.valueOf(now.toEpochMilli()), notBefore, notAfter, x500Name, keyPair.getPublic() + ).addExtension(Extension.basicConstraints, true, new BasicConstraints(true)); + return new JcaX509CertificateConverter() + .setProvider(new BouncyCastleProvider()) + .getCertificate(certificateBuilder.build(contentSigner)); + } catch (OperatorCreationException |IOException |CertificateException e) { + throw new RuntimeException(e); + } + } + + private static class Response { + + private final int status; + private final String body; + + private Response(int status, String body) { + this.status = status; + this.body = body; + } + } + +} |