From 4e6c3064514e8c502678b7d515a2c46eec6771fc Mon Sep 17 00:00:00 2001 From: Martin Polden Date: Fri, 20 Sep 2019 14:47:44 +0200 Subject: Implement REST API for instance registration --- athenz-identity-provider-service/pom.xml | 12 +++ .../ca/restapi/CertificateAuthorityApiHandler.java | 109 +++++++++++++++++++++ .../ca/restapi/CertificateAuthorityApiTest.java | 89 +++++++++++++++++ .../vespa/hosted/ca/restapi/ContainerTester.java | 69 +++++++++++++ .../hosted/ca/restapi/mock/SecretStoreMock.java | 34 +++++++ 5 files changed, 313 insertions(+) create mode 100644 athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/ca/restapi/CertificateAuthorityApiHandler.java create mode 100644 athenz-identity-provider-service/src/test/java/com/yahoo/vespa/hosted/ca/restapi/CertificateAuthorityApiTest.java create mode 100644 athenz-identity-provider-service/src/test/java/com/yahoo/vespa/hosted/ca/restapi/ContainerTester.java create mode 100644 athenz-identity-provider-service/src/test/java/com/yahoo/vespa/hosted/ca/restapi/mock/SecretStoreMock.java (limited to 'athenz-identity-provider-service') 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 @@ ${project.version} provided + + com.yahoo.vespa + container-dev + ${project.version} + provided + @@ -134,6 +140,12 @@ ${project.version} test + + com.yahoo.vespa + application + ${project.version} + test + com.yahoo.vespa testutil 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 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 "\n" + + " \n" + + " 10\n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " http://*/ca/v1/*\n" + + " \n" + + ""; + } + +} 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 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); + } + +} -- cgit v1.2.3