aboutsummaryrefslogtreecommitdiffstats
path: root/athenz-identity-provider-service
diff options
context:
space:
mode:
authorMartin Polden <mpolden@mpolden.no>2019-09-20 14:47:44 +0200
committerMartin Polden <mpolden@mpolden.no>2019-09-20 15:32:52 +0200
commit4e6c3064514e8c502678b7d515a2c46eec6771fc (patch)
treeeaafdef92dff617e71c6295b5bd915abfc77be52 /athenz-identity-provider-service
parente9caf7649d7bb235cdec15cb9593b9a0eac29c28 (diff)
Implement REST API for instance registration
Diffstat (limited to 'athenz-identity-provider-service')
-rw-r--r--athenz-identity-provider-service/pom.xml12
-rw-r--r--athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/ca/restapi/CertificateAuthorityApiHandler.java109
-rw-r--r--athenz-identity-provider-service/src/test/java/com/yahoo/vespa/hosted/ca/restapi/CertificateAuthorityApiTest.java89
-rw-r--r--athenz-identity-provider-service/src/test/java/com/yahoo/vespa/hosted/ca/restapi/ContainerTester.java69
-rw-r--r--athenz-identity-provider-service/src/test/java/com/yahoo/vespa/hosted/ca/restapi/mock/SecretStoreMock.java34
5 files changed, 313 insertions, 0 deletions
diff --git a/athenz-identity-provider-service/pom.xml b/athenz-identity-provider-service/pom.xml
index bdfebdf6169..a8de9b58269 100644
--- a/athenz-identity-provider-service/pom.xml
+++ b/athenz-identity-provider-service/pom.xml
@@ -94,6 +94,12 @@
<version>${project.version}</version>
<scope>provided</scope>
</dependency>
+ <dependency>
+ <groupId>com.yahoo.vespa</groupId>
+ <artifactId>container-dev</artifactId>
+ <version>${project.version}</version>
+ <scope>provided</scope>
+ </dependency>
<!-- COMPILE -->
<dependency>
@@ -134,6 +140,12 @@
<version>${project.version}</version>
<scope>test</scope>
</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>testutil</artifactId>
diff --git a/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/ca/restapi/CertificateAuthorityApiHandler.java b/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/ca/restapi/CertificateAuthorityApiHandler.java
new file mode 100644
index 00000000000..ba4f0ce932c
--- /dev/null
+++ b/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/ca/restapi/CertificateAuthorityApiHandler.java
@@ -0,0 +1,109 @@
+// Copyright 2019 Oath Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.vespa.hosted.ca.restapi;
+
+import com.google.inject.Inject;
+import com.yahoo.config.provision.SystemName;
+import com.yahoo.config.provision.Zone;
+import com.yahoo.container.jdisc.HttpRequest;
+import com.yahoo.container.jdisc.HttpResponse;
+import com.yahoo.container.jdisc.LoggingRequestHandler;
+import com.yahoo.container.jdisc.secretstore.SecretStore;
+import com.yahoo.restapi.Path;
+import com.yahoo.restapi.SlimeJsonResponse;
+import com.yahoo.security.KeyUtils;
+import com.yahoo.security.X509CertificateUtils;
+import com.yahoo.slime.Slime;
+import com.yahoo.vespa.config.SlimeUtils;
+import com.yahoo.vespa.hosted.ca.Certificates;
+import com.yahoo.vespa.hosted.ca.instance.InstanceIdentity;
+import com.yahoo.vespa.hosted.provision.restapi.v2.ErrorResponse;
+import com.yahoo.yolean.Exceptions;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.security.PrivateKey;
+import java.security.cert.X509Certificate;
+import java.time.Clock;
+import java.util.Optional;
+import java.util.logging.Level;
+
+/**
+ * REST API for issuing and refreshing node certificates in a hosted Vespa system.
+ *
+ * The API implements the following subset of methods from the Athenz ZTS REST API:
+ * - Instance registration
+ * - Instance refresh
+ *
+ * @author mpolden
+ */
+public class CertificateAuthorityApiHandler extends LoggingRequestHandler {
+
+ private final SecretStore secretStore;
+ private final Certificates certificates;
+ private final SystemName system;
+
+ @Inject
+ public CertificateAuthorityApiHandler(Context ctx, SecretStore secretStore, Zone zone) {
+ this(ctx, secretStore, new Certificates(Clock.systemUTC()), zone.system());
+ }
+
+ CertificateAuthorityApiHandler(Context ctx, SecretStore secretStore, Certificates certificates, SystemName system) {
+ super(ctx);
+ this.secretStore = secretStore;
+ this.certificates = certificates;
+ this.system = system;
+ }
+
+ @Override
+ public HttpResponse handle(HttpRequest request) {
+ try {
+ switch (request.getMethod()) {
+ case POST: return handlePost(request);
+ default: return ErrorResponse.methodNotAllowed("Method '" + request.getMethod() + "' is unsupported");
+ }
+ } catch (IllegalArgumentException e) {
+ return ErrorResponse.badRequest(Exceptions.toMessageString(e));
+ } catch (RuntimeException e) {
+ log.log(Level.WARNING, "Unexpected error handling '" + request.getUri() + "'", e);
+ return ErrorResponse.internalServerError(Exceptions.toMessageString(e));
+ }
+ }
+
+ private HttpResponse handlePost(HttpRequest request) {
+ Path path = new Path(request.getUri());
+ if (path.matches("/ca/v1/instance/")) return registerInstance(request);
+ // TODO: Implement refresh
+ return ErrorResponse.notFoundError("Nothing at " + path);
+ }
+
+ private HttpResponse registerInstance(HttpRequest request) {
+ var body = slimeFromRequest(request);
+ var instanceRegistration = InstanceSerializer.registrationFromSlime(body);
+ var certificate = certificates.create(instanceRegistration.csr(), caCertificate(), caPrivateKey());
+ var instanceId = Certificates.extractDnsName(instanceRegistration.csr());
+ var identity = new InstanceIdentity(instanceRegistration.provider(), instanceRegistration.service(), instanceId,
+ Optional.of(certificate));
+ return new SlimeJsonResponse(InstanceSerializer.identityToSlime(identity));
+ }
+
+ /** Returns CA certificate from secret store */
+ private X509Certificate caCertificate() {
+ var keyName = String.format("vespa.external.%s.configserver.ca.cert.cert", system.value().toLowerCase());
+ return X509CertificateUtils.fromPem(secretStore.getSecret(keyName));
+ }
+
+ /** Returns CA private key from secret store */
+ private PrivateKey caPrivateKey() {
+ var keyName = String.format("vespa.external.%s.configserver.ca.key.key", system.value().toLowerCase());
+ return KeyUtils.fromPemEncodedPrivateKey(secretStore.getSecret(keyName));
+ }
+
+ private static Slime slimeFromRequest(HttpRequest request) {
+ try {
+ return SlimeUtils.jsonToSlime(request.getData().readAllBytes());
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ }
+
+}
diff --git a/athenz-identity-provider-service/src/test/java/com/yahoo/vespa/hosted/ca/restapi/CertificateAuthorityApiTest.java b/athenz-identity-provider-service/src/test/java/com/yahoo/vespa/hosted/ca/restapi/CertificateAuthorityApiTest.java
new file mode 100644
index 00000000000..1598f69a5f4
--- /dev/null
+++ b/athenz-identity-provider-service/src/test/java/com/yahoo/vespa/hosted/ca/restapi/CertificateAuthorityApiTest.java
@@ -0,0 +1,89 @@
+// Copyright 2019 Oath Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.vespa.hosted.ca.restapi;
+
+import com.yahoo.application.container.handler.Request;
+import com.yahoo.security.KeyAlgorithm;
+import com.yahoo.security.KeyUtils;
+import com.yahoo.security.Pkcs10Csr;
+import com.yahoo.security.Pkcs10CsrUtils;
+import com.yahoo.security.X509CertificateUtils;
+import com.yahoo.vespa.config.SlimeUtils;
+import com.yahoo.vespa.hosted.ca.CertificateTester;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.nio.charset.StandardCharsets;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * @author mpolden
+ */
+public class CertificateAuthorityApiTest extends ContainerTester {
+
+ @Before
+ public void before() {
+ setCaCertificateAndKey();
+ }
+
+ @Test
+ public void register_instance() {
+ // POST instance registration
+ var csr = CertificateTester.createCsr("node1.example.com");
+ assertRegistration(new Request("http://localhost:8080/ca/v1/instance/",
+ instanceRegistrationJson(csr),
+ Request.Method.POST));
+ }
+
+ @Test
+ public void invalid_register_instance() {
+ // POST instance registration with missing fields
+ assertResponse(400, "{\"error-code\":\"BAD_REQUEST\",\"message\":\"Missing required field 'provider'\"}",
+ new Request("http://localhost:8080/ca/v1/instance/",
+ new byte[0],
+ Request.Method.POST));
+
+ // POST instance registration without DNS name in CSR
+ var csr = CertificateTester.createCsr();
+ var request = new Request("http://localhost:8080/ca/v1/instance/",
+ instanceRegistrationJson(csr),
+ Request.Method.POST);
+ assertResponse(400, "{\"error-code\":\"BAD_REQUEST\",\"message\":\"DNS name not found in CSR\"}", request);
+ }
+
+ private void setCaCertificateAndKey() {
+ var keyPair = KeyUtils.generateKeypair(KeyAlgorithm.EC, 256);
+ var caCertificatePem = X509CertificateUtils.toPem(CertificateTester.createCertificate("Vespa CA", keyPair));
+ var privateKeyPem = KeyUtils.toPem(keyPair.getPrivate());
+ secretStore().setSecret("vespa.external.main.configserver.ca.cert.cert", caCertificatePem)
+ .setSecret("vespa.external.main.configserver.ca.key.key", privateKeyPem);
+ }
+
+ private void assertRegistration(Request request) {
+ assertResponse(200, (body) -> {
+ var slime = SlimeUtils.jsonToSlime(body);
+ var root = slime.get();
+ assertEquals("provider_prod_us-north-1", root.field("provider").asString());
+ assertEquals("tenant", root.field("service").asString());
+ assertEquals("node1.example.com", root.field("instanceId").asString());
+ var pemEncodedCertificate = root.field("x509Certificate").asString();
+ assertTrue("Response contains PEM certificate",
+ pemEncodedCertificate.startsWith("-----BEGIN CERTIFICATE-----") &&
+ pemEncodedCertificate.endsWith("-----END CERTIFICATE-----\n"));
+ }, request);
+ }
+
+ private static byte[] instanceRegistrationJson(Pkcs10Csr csr) {
+ var csrPem = Pkcs10CsrUtils.toPem(csr);
+ var json = "{\n" +
+ " \"provider\": \"provider_prod_us-north-1\",\n" +
+ " \"domain\": \"vespa.external\",\n" +
+ " \"service\": \"tenant\",\n" +
+ " \"attestationData\": \"identity document generated by config server\",\n" +
+ " \"csr\": \"" + csrPem + "\"\n" +
+ "}";
+ return json.getBytes(StandardCharsets.UTF_8);
+ }
+
+}
diff --git a/athenz-identity-provider-service/src/test/java/com/yahoo/vespa/hosted/ca/restapi/ContainerTester.java b/athenz-identity-provider-service/src/test/java/com/yahoo/vespa/hosted/ca/restapi/ContainerTester.java
new file mode 100644
index 00000000000..6cc86839290
--- /dev/null
+++ b/athenz-identity-provider-service/src/test/java/com/yahoo/vespa/hosted/ca/restapi/ContainerTester.java
@@ -0,0 +1,69 @@
+// Copyright 2019 Oath Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.vespa.hosted.ca.restapi;
+
+import com.yahoo.application.Networking;
+import com.yahoo.application.container.JDisc;
+import com.yahoo.application.container.handler.Request;
+import com.yahoo.vespa.hosted.ca.restapi.mock.SecretStoreMock;
+import org.junit.After;
+import org.junit.Before;
+
+import java.io.UncheckedIOException;
+import java.nio.charset.CharacterCodingException;
+import java.util.function.Consumer;
+
+import static org.junit.Assert.assertEquals;
+
+/**
+ * The superclass of REST API tests which require a functional container instance.
+ *
+ * @author mpolden
+ */
+public class ContainerTester {
+
+ private JDisc container;
+
+ @Before
+ public void startContainer() {
+ container = JDisc.fromServicesXml(servicesXml(), Networking.disable);
+ }
+
+ @After
+ public void stopContainer() {
+ container.close();
+ }
+
+ public SecretStoreMock secretStore() {
+ return (SecretStoreMock) container.components().getComponent(SecretStoreMock.class.getName());
+ }
+
+ public void assertResponse(int expectedStatus, String expectedBody, Request request) {
+ assertResponse(expectedStatus, (body) -> assertEquals(expectedBody, body), request);
+ }
+
+ public void assertResponse(int expectedStatus, Consumer<String> bodyAsserter, Request request) {
+ var response = container.handleRequest(request);
+ try {
+ bodyAsserter.accept(response.getBodyAsString());
+ } catch (CharacterCodingException e) {
+ throw new UncheckedIOException(e);
+ }
+ assertEquals(expectedStatus, response.getStatus());
+ assertEquals("application/json; charset=UTF-8", response.getHeaders().getFirst("Content-Type"));
+ }
+
+ private static String servicesXml() {
+ return "<container version='1.0'>\n" +
+ " <config name=\"container.handler.threadpool\">\n" +
+ " <maxthreads>10</maxthreads>\n" +
+ " </config> \n" +
+ " <component id='com.yahoo.vespa.hosted.provision.testutils.MockNodeFlavors'/>\n" +
+ " <component id='com.yahoo.config.provision.Zone'/>\n" +
+ " <component id='com.yahoo.vespa.hosted.ca.restapi.mock.SecretStoreMock'/>\n" +
+ " <handler id='com.yahoo.vespa.hosted.ca.restapi.CertificateAuthorityApiHandler'>\n" +
+ " <binding>http://*/ca/v1/*</binding>\n" +
+ " </handler>\n" +
+ "</container>";
+ }
+
+}
diff --git a/athenz-identity-provider-service/src/test/java/com/yahoo/vespa/hosted/ca/restapi/mock/SecretStoreMock.java b/athenz-identity-provider-service/src/test/java/com/yahoo/vespa/hosted/ca/restapi/mock/SecretStoreMock.java
new file mode 100644
index 00000000000..a53bf9d9fd3
--- /dev/null
+++ b/athenz-identity-provider-service/src/test/java/com/yahoo/vespa/hosted/ca/restapi/mock/SecretStoreMock.java
@@ -0,0 +1,34 @@
+// Copyright 2019 Oath Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.vespa.hosted.ca.restapi.mock;
+
+import com.yahoo.component.AbstractComponent;
+import com.yahoo.container.jdisc.secretstore.SecretStore;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * @author mpolden
+ */
+public class SecretStoreMock extends AbstractComponent implements SecretStore {
+
+ private final Map<String, String> secrets = new HashMap<>();
+
+ public SecretStoreMock setSecret(String key, String value) {
+ secrets.put(key, value);
+ return this;
+ }
+
+ @Override
+ public String getSecret(String key) {
+ if (!secrets.containsKey(key)) throw new RuntimeException("No such key '" + key + "'");
+ return secrets.get(key);
+ }
+
+ @Override
+ public String getSecret(String key, int version) {
+ if (!secrets.containsKey(key)) throw new RuntimeException("No such key '" + key + "'");
+ return secrets.get(key);
+ }
+
+}