summaryrefslogtreecommitdiffstats
path: root/node-repository
diff options
context:
space:
mode:
authorMartin Polden <mpolden@mpolden.no>2018-02-21 11:31:18 +0100
committerGitHub <noreply@github.com>2018-02-21 11:31:18 +0100
commit15e8b4456a6b57ea8b144d175641b6032a0be267 (patch)
tree1ba8f9805257718f8dcbc81efbc257918b2cc6dd /node-repository
parent59e254a7d8ea61ddfb2adde30b0aecef70913704 (diff)
parent3564bc128ce89573799419a31b8736332eeb432e (diff)
Merge pull request #5080 from vespa-engine/mpolden/node-repo-auth-filter
Authorization filter for node repository
Diffstat (limited to 'node-repository')
-rw-r--r--node-repository/pom.xml55
-rw-r--r--node-repository/src/main/java/com/yahoo/vespa/hosted/provision/restapi/v2/Authorizer.java113
-rw-r--r--node-repository/src/main/java/com/yahoo/vespa/hosted/provision/restapi/v2/ErrorResponse.java28
-rw-r--r--node-repository/src/main/java/com/yahoo/vespa/hosted/provision/restapi/v2/filter/AuthorizationFilter.java112
-rw-r--r--node-repository/src/main/java/com/yahoo/vespa/hosted/provision/restapi/v2/filter/package-info.java5
-rw-r--r--node-repository/src/main/java/com/yahoo/vespa/hosted/provision/testutils/MockNodeRepository.java2
-rw-r--r--node-repository/src/test/java/com/yahoo/vespa/hosted/provision/restapi/v2/AuthorizerTest.java78
-rw-r--r--node-repository/src/test/java/com/yahoo/vespa/hosted/provision/restapi/v2/filter/AuthorizationFilterTest.java156
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;
+ }
+ }
+
+}