diff options
42 files changed, 1877 insertions, 12 deletions
diff --git a/athenz-identity-provider-service/OWNERS b/athenz-identity-provider-service/OWNERS new file mode 100644 index 00000000000..569bf1cc3a1 --- /dev/null +++ b/athenz-identity-provider-service/OWNERS @@ -0,0 +1 @@ +bjorncs diff --git a/athenz-identity-provider-service/pom.xml b/athenz-identity-provider-service/pom.xml new file mode 100644 index 00000000000..074d4aa54f4 --- /dev/null +++ b/athenz-identity-provider-service/pom.xml @@ -0,0 +1,144 @@ +<?xml version="1.0"?> +<project xmlns="http://maven.apache.org/POM/4.0.0" + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 + http://maven.apache.org/xsd/maven-4.0.0.xsd"> + <modelVersion>4.0.0</modelVersion> + <artifactId>athenz-identity-provider-service</artifactId> + <packaging>container-plugin</packaging> + <parent> + <groupId>com.yahoo.vespa</groupId> + <artifactId>parent</artifactId> + <version>6-SNAPSHOT</version> + </parent> + <dependencies> + <!-- COMPILE --> + <dependency> + <groupId>com.yahoo.athenz</groupId> + <artifactId>athenz-zms-java-client</artifactId> + <scope>compile</scope> + <exclusions> + <!--Exclude all Jersey bundles provided by JDisc--> + <exclusion> + <groupId>org.glassfish.jersey.core</groupId> + <artifactId>jersey-client</artifactId> + </exclusion> + <exclusion> + <groupId>org.glassfish.jersey.media</groupId> + <artifactId>jersey-media-json-jackson</artifactId> + </exclusion> + <!-- BouncyCastle is not bundled due to class loading issues + when security provider is registered from inside a OSGi bundle --> + <exclusion> + <groupId>org.bouncycastle</groupId> + <artifactId>bcpkix-jdk15on</artifactId> + </exclusion> + <exclusion> + <groupId>org.bouncycastle</groupId> + <artifactId>bcprov-jdk15on</artifactId> + </exclusion> + </exclusions> + </dependency> + <dependency> + <groupId>com.yahoo.athenz</groupId> + <artifactId>athenz-zts-java-client</artifactId> + <scope>compile</scope> + </dependency> + <dependency> + <groupId>com.fasterxml.jackson.datatype</groupId> + <artifactId>jackson-datatype-jsr310</artifactId> + <scope>compile</scope> + </dependency> + + <!-- PROVIDED --> + <!-- BouncyCastle should be available through jdisc_http_service at runtime --> + <dependency> + <groupId>org.bouncycastle</groupId> + <artifactId>bcpkix-jdk15on</artifactId> + <scope>provided</scope> + </dependency> + <dependency> + <groupId>org.bouncycastle</groupId> + <artifactId>bcprov-jdk15on</artifactId> + <scope>provided</scope> + </dependency> + <dependency> + <groupId>com.yahoo.vespa</groupId> + <artifactId>container-dev</artifactId> + <version>${project.version}</version> + <scope>provided</scope> + </dependency> + <dependency> + <groupId>org.eclipse.jetty</groupId> + <artifactId>jetty-server</artifactId> + <scope>provided</scope> + </dependency> + <dependency> + <groupId>org.eclipse.jetty</groupId> + <artifactId>jetty-servlet</artifactId> + <scope>provided</scope> + </dependency> + <dependency> + <groupId>com.yahoo.vespa</groupId> + <artifactId>node-repository</artifactId> + <version>${project.version}</version> + <scope>provided</scope> + </dependency> + <dependency> + <groupId>com.yahoo.vespa</groupId> + <artifactId>config-provisioning</artifactId> + <version>${project.version}</version> + </dependency> + <dependency> + <groupId>com.yahoo.vespa</groupId> + <artifactId>container-disc</artifactId> + <version>${project.version}</version> + <scope>provided</scope> + </dependency> + + <!-- TEST --> + <dependency> + <groupId>junit</groupId> + <artifactId>junit</artifactId> + <scope>test</scope> + </dependency> + <dependency> + <groupId>com.yahoo.vespa</groupId> + <artifactId>container-test</artifactId> + <version>${project.version}</version> + <scope>test</scope> + </dependency> + <dependency> + <groupId>org.apache.httpcomponents</groupId> + <artifactId>httpclient</artifactId> + <version>4.4.1</version> + <scope>test</scope> + </dependency> + <dependency> + <groupId>org.apache.httpcomponents</groupId> + <artifactId>httpcore</artifactId> + <version>4.4.1</version> + <scope>test</scope> + </dependency> + <dependency> + <groupId>org.mockito</groupId> + <artifactId>mockito-all</artifactId> + <scope>test</scope> + </dependency> + </dependencies> + + <build> + <plugins> + <plugin> + <groupId>com.yahoo.vespa</groupId> + <artifactId>bundle-plugin</artifactId> + <extensions>true</extensions> + </plugin> + <plugin> + <groupId>org.apache.maven.plugins</groupId> + <artifactId>maven-compiler-plugin</artifactId> + </plugin> + </plugins> + </build> + +</project> diff --git a/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/AthenzInstanceProviderService.java b/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/AthenzInstanceProviderService.java new file mode 100644 index 00000000000..301d6250b31 --- /dev/null +++ b/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/AthenzInstanceProviderService.java @@ -0,0 +1,182 @@ +// 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.athenz.instanceproviderservice; + +import com.google.inject.Inject; +import com.yahoo.athenz.auth.impl.PrincipalAuthority; +import com.yahoo.athenz.auth.impl.SimpleServiceIdentityProvider; +import com.yahoo.athenz.auth.util.Crypto; +import com.yahoo.athenz.zts.InstanceRefreshRequest; +import com.yahoo.athenz.zts.ZTSClient; +import com.yahoo.component.AbstractComponent; +import com.yahoo.config.provision.Zone; +import com.yahoo.jdisc.http.ssl.ReaderForPath; +import com.yahoo.jdisc.http.ssl.pem.PemKeyStore; +import com.yahoo.jdisc.http.ssl.pem.PemSslKeyStore; +import com.yahoo.log.LogLevel; +import com.yahoo.vespa.hosted.athenz.identityproviderservice.config.AthenzProviderServiceConfig; +import com.yahoo.vespa.hosted.athenz.instanceproviderservice.impl.FileBackedKeyProvider; +import com.yahoo.vespa.hosted.athenz.instanceproviderservice.impl.IdentityDocumentGenerator; +import com.yahoo.vespa.hosted.athenz.instanceproviderservice.impl.InstanceValidator; +import com.yahoo.vespa.hosted.athenz.instanceproviderservice.impl.KeyProvider; +import com.yahoo.vespa.hosted.athenz.instanceproviderservice.impl.ProviderServiceServlet; +import com.yahoo.vespa.hosted.athenz.instanceproviderservice.impl.StatusServlet; +import com.yahoo.vespa.hosted.provision.NodeRepository; +import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.server.ServerConnector; +import org.eclipse.jetty.servlet.ServletHandler; +import org.eclipse.jetty.servlet.ServletHolder; +import org.eclipse.jetty.util.ssl.SslContextFactory; + +import java.io.StringReader; +import java.security.KeyStore; +import java.security.PrivateKey; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.logging.Logger; + +/** + * A component acting as both SIA for configserver and provides a lightweight Jetty instance hosting the InstanceConfirmation API + * + * @author bjorncs + */ +public class AthenzInstanceProviderService extends AbstractComponent { + + private static final Logger log = Logger.getLogger(AthenzInstanceProviderService.class.getName()); + + private final ScheduledExecutorService scheduler; + private final Server jetty; + + @Inject + public AthenzInstanceProviderService(AthenzProviderServiceConfig config, NodeRepository nodeRepository, Zone zone) { + this(config, new FileBackedKeyProvider(config.keyPathPrefix()), Executors.newSingleThreadScheduledExecutor(), + nodeRepository, zone); + } + + AthenzInstanceProviderService(AthenzProviderServiceConfig config, + KeyProvider keyProvider, + ScheduledExecutorService scheduler, NodeRepository nodeRepository, Zone zone) { + this.scheduler = scheduler; + SslContextFactory sslContextFactory = createSslContextFactory(); + this.jetty = createJettyServer(config, keyProvider, sslContextFactory, + nodeRepository, zone); + AthenzCertificateUpdater reloader = new AthenzCertificateUpdater( + sslContextFactory, keyProvider, config); + scheduler.scheduleAtFixedRate(reloader, 0, 1, TimeUnit.DAYS); + try { + jetty.start(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private static Server createJettyServer(AthenzProviderServiceConfig config, + KeyProvider keyProvider, + SslContextFactory sslContextFactory, + NodeRepository nodeRepository, + Zone zone) { + Server server = new Server(); + ServerConnector connector = new ServerConnector(server, sslContextFactory); + connector.setPort(config.port()); + server.addConnector(connector); + + ServletHandler handler = new ServletHandler(); + ProviderServiceServlet providerServiceServlet = + new ProviderServiceServlet(new InstanceValidator(keyProvider), new IdentityDocumentGenerator(config, nodeRepository, zone, keyProvider)); + handler.addServletWithMapping(new ServletHolder(providerServiceServlet), config.apiPath()); + handler.addServletWithMapping(StatusServlet.class, "/status.html"); + server.setHandler(handler); + return server; + + } + + private static SslContextFactory createSslContextFactory() { + try { + SslContextFactory sslContextFactory = new SslContextFactory(); + sslContextFactory.setWantClientAuth(true); + sslContextFactory.setProtocol("TLS"); + sslContextFactory.setKeyManagerFactoryAlgorithm("SunX509"); + return sslContextFactory; + } catch (Exception e) { + throw new IllegalArgumentException("Failed to create SSL context factory: " + e.getMessage(), e); + } + } + + private static class AthenzCertificateUpdater implements Runnable { + + private static final Logger log = Logger.getLogger(AthenzCertificateUpdater.class.getName()); + + private final SslContextFactory sslContextFactory; + private final KeyProvider keyProvider; + private final AthenzProviderServiceConfig config; + + private AthenzCertificateUpdater(SslContextFactory sslContextFactory, + KeyProvider keyProvider, + AthenzProviderServiceConfig config) { + this.sslContextFactory = sslContextFactory; + this.keyProvider = keyProvider; + this.config = config; + } + + @Override + public void run() { + try { + log.log(LogLevel.INFO, "Updating Athenz certificate through ZTS"); + String privateKey = keyProvider.getPrivateKey(config.keyVersion()); + String certificate = getCertificateFromZTS(Crypto.loadPrivateKey(privateKey)); + final KeyStore keyStore = + new PemSslKeyStore( + new PemKeyStore.KeyStoreLoadParameter( + new ReaderForPath(new StringReader(certificate), null), + new ReaderForPath(new StringReader(privateKey), null))) + .loadJavaKeyStore(); + sslContextFactory.reload(sslContextFactory -> sslContextFactory.setKeyStore(keyStore)); + log.log(LogLevel.INFO, "Athenz certificate reload successfully completed"); + } catch (Exception e) { + log.log(LogLevel.ERROR, "Failed to update certificate from ZTS: " + e.getMessage(), e); + } + } + + private String getCertificateFromZTS(PrivateKey privateKey) { + SimpleServiceIdentityProvider identityProvider = new SimpleServiceIdentityProvider( + new AthenzPrincipalAuthority(config.athenzPrincipalHeaderName()), config.domain(), config.serviceName(), + privateKey, Integer.toString(config.keyVersion()), TimeUnit.MINUTES.toSeconds(10)); + ZTSClient ztsClient = new ZTSClient( + config.ztsUrl(), config.domain(), config.serviceName(), identityProvider); + InstanceRefreshRequest req = ZTSClient.generateInstanceRefreshRequest( + config.domain(), config.serviceName(), privateKey, config.certDnsSuffix(), (int)TimeUnit.DAYS.toSeconds(30)); + return ztsClient.postInstanceRefreshRequest(config.domain(), config.serviceName(), req).getCertificate(); + } + + private static class AthenzPrincipalAuthority extends PrincipalAuthority { + private final String headerName; + + public AthenzPrincipalAuthority(String headerName) { + this.headerName = headerName; + } + + @Override + public String getHeader() { + return headerName; + } + } + } + + @Override + public void deconstruct() { + try { + log.log(LogLevel.INFO, "Deconstructing Athenz provider service"); + scheduler.shutdown(); + jetty.stop(); + if (!scheduler.awaitTermination(1, TimeUnit.MINUTES)) { + log.log(LogLevel.ERROR, "Failed to stop certificate updater"); + } + } catch (InterruptedException e) { + log.log(LogLevel.ERROR, "Failed to stop certificate updater: " + e.getMessage(), e); + } catch (Exception e) { + log.log(LogLevel.ERROR, "Failed to stop Jetty: " + e.getMessage(), e); + } finally { + super.deconstruct(); + } + } +} diff --git a/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/impl/FileBackedKeyProvider.java b/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/impl/FileBackedKeyProvider.java new file mode 100644 index 00000000000..f03f8415586 --- /dev/null +++ b/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/impl/FileBackedKeyProvider.java @@ -0,0 +1,40 @@ +// 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.athenz.instanceproviderservice.impl; + +import java.io.File; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; + +/** + * @author bjorncs + */ +public class FileBackedKeyProvider implements KeyProvider { + + private final String keyPathPrefix; + + public FileBackedKeyProvider(String keyPathPrefix) { + this.keyPathPrefix = keyPathPrefix; + } + + @Override + public String getPrivateKey(int version) { + return loadKey(new File(keyPathPrefix + ".priv." + version)); + } + + @Override + public String getPublicKey(int version) { + return loadKey(new File(keyPathPrefix + ".pub." + version)); + } + + private static String loadKey(File file) { + try { + if (!file.exists() || !file.isFile()) { + throw new IllegalArgumentException("Key missing: " + file.getAbsolutePath()); + } + return new String(Files.readAllBytes(file.toPath())); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } +} diff --git a/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/impl/IdentityDocumentGenerator.java b/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/impl/IdentityDocumentGenerator.java new file mode 100644 index 00000000000..6a8e3c01b55 --- /dev/null +++ b/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/impl/IdentityDocumentGenerator.java @@ -0,0 +1,89 @@ +package com.yahoo.vespa.hosted.athenz.instanceproviderservice.impl; + +import com.yahoo.athenz.auth.util.Crypto; +import com.yahoo.config.provision.Zone; +import com.yahoo.vespa.hosted.athenz.identityproviderservice.config.AthenzProviderServiceConfig; +import com.yahoo.vespa.hosted.athenz.instanceproviderservice.impl.model.IdentityDocument; +import com.yahoo.vespa.hosted.athenz.instanceproviderservice.impl.model.ProviderUniqueId; +import com.yahoo.vespa.hosted.athenz.instanceproviderservice.impl.model.SignedIdentityDocument; +import com.yahoo.vespa.hosted.provision.Node; +import com.yahoo.vespa.hosted.provision.NodeRepository; +import com.yahoo.vespa.hosted.provision.node.Allocation; + +import java.security.PrivateKey; +import java.security.Signature; +import java.time.Instant; +import java.util.Base64; + +/** + * @author mortent + */ +public class IdentityDocumentGenerator { + + private final NodeRepository nodeRepository; + private final Zone zone; + private final KeyProvider keyProvider; + private final String dnsSuffix; + private final String providerService; + private final String ztsUrl; + + public IdentityDocumentGenerator(AthenzProviderServiceConfig config, NodeRepository nodeRepository, Zone zone, KeyProvider keyProvider) { + this.nodeRepository = nodeRepository; + this.zone = zone; + this.keyProvider = keyProvider; + this.dnsSuffix = config.certDnsSuffix(); + this.providerService = config.serviceName(); + this.ztsUrl = config.ztsUrl(); + } + + public String generateSignedIdentityDocument(String hostname) { + Node node = nodeRepository.getNode(hostname).orElseThrow(() -> new RuntimeException("Unable to find node " + hostname)); + try { + IdentityDocument identityDocument = generateIdDocument(node); + String identityDocumentString = Utils.getMapper().writeValueAsString(identityDocument); + + String encodedIdentityDocument = + Base64.getEncoder().encodeToString(identityDocumentString.getBytes()); + Signature sigGenerator = Signature.getInstance("SHA512withRSA"); + + // TODO: Get the correct version 0 ok for now + PrivateKey privateKey = Crypto.loadPrivateKey(keyProvider.getPrivateKey(0)); + sigGenerator.initSign(privateKey); + sigGenerator.update(encodedIdentityDocument.getBytes()); + String signature = Base64.getEncoder().encodeToString(sigGenerator.sign()); + + SignedIdentityDocument signedIdentityDocument = new SignedIdentityDocument( + encodedIdentityDocument, + signature, + SignedIdentityDocument.DEFAULT_KEY_VERSION, + identityDocument.providerUniqueId.asString(), + dnsSuffix, + providerService, + ztsUrl, + SignedIdentityDocument.DEFAILT_DOCUMENT_VERSION + ); + return Utils.getMapper().writeValueAsString(signedIdentityDocument); + } catch (Exception e) { + throw new RuntimeException("Exception generating identity document: " + e.getMessage(), e); + } + } + + private IdentityDocument generateIdDocument(Node node) { + Allocation allocation = node.allocation().orElseThrow(() -> new RuntimeException("No allocation for node " + node.hostname())); + ProviderUniqueId providerUniqueId = new ProviderUniqueId( + allocation.owner().tenant().value(), + allocation.owner().application().value(), + zone.environment().value(), + zone.region().value(), + allocation.owner().instance().value(), + allocation.membership().cluster().id().value(), + allocation.membership().index()); + + return new IdentityDocument( + providerUniqueId, + "localhost", // TODO: Add configserver hostname + node.hostname(), + Instant.now()); + } +} + diff --git a/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/impl/InstanceValidator.java b/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/impl/InstanceValidator.java new file mode 100644 index 00000000000..f5c2c319041 --- /dev/null +++ b/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/impl/InstanceValidator.java @@ -0,0 +1,56 @@ +// 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.athenz.instanceproviderservice.impl; + +import com.yahoo.athenz.auth.util.Crypto; +import com.yahoo.log.LogLevel; +import com.yahoo.vespa.hosted.athenz.instanceproviderservice.impl.model.InstanceConfirmation; +import com.yahoo.vespa.hosted.athenz.instanceproviderservice.impl.model.ProviderUniqueId; +import com.yahoo.vespa.hosted.athenz.instanceproviderservice.impl.model.SignedIdentityDocument; + +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.security.PublicKey; +import java.security.Signature; +import java.security.SignatureException; +import java.util.Base64; +import java.util.logging.Logger; + +/** + * Verifies that the instance's identity document is valid + * + * @author bjorncs + */ +public class InstanceValidator { + + private static final Logger log = Logger.getLogger(InstanceValidator.class.getName()); + + private final KeyProvider keyProvider; + + public InstanceValidator(KeyProvider keyProvider) { + this.keyProvider = keyProvider; + } + + public boolean isValidInstance(InstanceConfirmation instanceConfirmation) { + SignedIdentityDocument signedIdentityDocument = instanceConfirmation.signedIdentityDocument; + ProviderUniqueId providerUniqueId = signedIdentityDocument.identityDocument.providerUniqueId; + log.log(LogLevel.INFO, () -> String.format("Validating instance %s.", providerUniqueId)); + PublicKey publicKey = Crypto.loadPublicKey(keyProvider.getPublicKey(signedIdentityDocument.signingKeyVersion)); + if (isSignatureValid(publicKey, signedIdentityDocument.rawIdentityDocument, signedIdentityDocument.signature)) { + log.log(LogLevel.INFO, () -> String.format("Instance %s is valid.", providerUniqueId)); + return true; + } + log.log(LogLevel.ERROR, () -> String.format("Instance %s has invalid signature.", providerUniqueId)); + return false; + } + + public static boolean isSignatureValid(PublicKey publicKey, String rawIdentityDocument, String signature) { + try { + Signature signatureVerifier = Signature.getInstance("SHA512withRSA"); + signatureVerifier.initVerify(publicKey); + signatureVerifier.update(rawIdentityDocument.getBytes()); + return signatureVerifier.verify(Base64.getDecoder().decode(signature)); + } catch (NoSuchAlgorithmException | InvalidKeyException | SignatureException e) { + throw new RuntimeException(e); + } + } +} diff --git a/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/impl/KeyProvider.java b/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/impl/KeyProvider.java new file mode 100644 index 00000000000..8c807405693 --- /dev/null +++ b/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/impl/KeyProvider.java @@ -0,0 +1,11 @@ +// 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.athenz.instanceproviderservice.impl; + +/** + * @author bjorncs + */ +public interface KeyProvider { + String getPrivateKey(int version); + + String getPublicKey(int version); +} diff --git a/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/impl/ProviderServiceServlet.java b/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/impl/ProviderServiceServlet.java new file mode 100644 index 00000000000..7766dc9cc3c --- /dev/null +++ b/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/impl/ProviderServiceServlet.java @@ -0,0 +1,81 @@ +// 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.athenz.instanceproviderservice.impl; + +import com.fasterxml.jackson.core.JsonParseException; +import com.fasterxml.jackson.databind.JsonMappingException; +import com.yahoo.log.LogLevel; +import com.yahoo.vespa.hosted.athenz.instanceproviderservice.impl.model.InstanceConfirmation; + +import javax.servlet.ServletException; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.PrintWriter; +import java.io.Reader; +import java.util.logging.Logger; +import java.util.stream.Collectors; + +/** + * A Servlet implementing the Athenz Service Provider InstanceConfirmation API + * + * @author bjorncs + */ +public class ProviderServiceServlet extends HttpServlet { + + private static final Logger log = Logger.getLogger(ProviderServiceServlet.class.getName()); + + private final InstanceValidator instanceValidator; + private final IdentityDocumentGenerator identityDocumentGenerator; + + public ProviderServiceServlet(InstanceValidator instanceValidator, IdentityDocumentGenerator identityDocumentGenerator) { + this.instanceValidator = instanceValidator; + this.identityDocumentGenerator = identityDocumentGenerator; + } + + @Override + protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { + // TODO Validate that request originates from ZTS + try { + String confirmationContent = toString(req.getReader()); + log.log(LogLevel.DEBUG, () -> "Confirmation content: " + confirmationContent); + InstanceConfirmation instanceConfirmation = + Utils.getMapper().readValue(confirmationContent, InstanceConfirmation.class); + log.log(LogLevel.DEBUG, () -> "Parsed confirmation content: " + instanceConfirmation.toString()); + if (!instanceValidator.isValidInstance(instanceConfirmation)) { + log.log(LogLevel.ERROR, "Invalid instance: " + instanceConfirmation); + resp.setStatus(HttpServletResponse.SC_FORBIDDEN); + } else { + resp.setStatus(HttpServletResponse.SC_OK); + resp.setContentType("application/json"); + resp.getWriter().write(Utils.getMapper().writeValueAsString(instanceConfirmation)); + } + } catch (JsonParseException | JsonMappingException e) { + log.log(LogLevel.ERROR, "InstanceConfirmation is not valid JSON", e); + resp.setStatus(HttpServletResponse.SC_BAD_REQUEST); + } + } + + @Override + protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { + // TODO verify tls client cert + String hostname = req.getParameter("hostname"); + try { + String signedIdentityDocument = identityDocumentGenerator.generateSignedIdentityDocument(hostname); + resp.setContentType("application/json"); + PrintWriter writer = resp.getWriter(); + writer.print(signedIdentityDocument); + writer.flush(); + } catch (Exception e) { + resp.sendError(HttpServletResponse.SC_NOT_FOUND, String.format("Unable to generate identity doument [%s]", e.getMessage())); + } + } + + private static String toString(Reader reader) throws IOException { + try (BufferedReader bufferedReader = new BufferedReader(reader)) { + return bufferedReader.lines().collect(Collectors.joining("\n")); + } + } + +} diff --git a/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/impl/StatusServlet.java b/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/impl/StatusServlet.java new file mode 100644 index 00000000000..fd5ba5843aa --- /dev/null +++ b/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/impl/StatusServlet.java @@ -0,0 +1,21 @@ +// 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.athenz.instanceproviderservice.impl; + +import javax.servlet.ServletException; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; + +/** + * A simple status servlet that should return status code 200 as long as the provider service servlet is up. + * + * @author bjorncs + */ +public class StatusServlet extends HttpServlet { + + @Override + protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { + resp.setStatus(HttpServletResponse.SC_OK); + } +} diff --git a/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/impl/Utils.java b/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/impl/Utils.java new file mode 100644 index 00000000000..d81ec183fd4 --- /dev/null +++ b/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/impl/Utils.java @@ -0,0 +1,23 @@ +// 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.athenz.instanceproviderservice.impl; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; + +/** + * @author bjorncs + */ +public class Utils { + + private static final ObjectMapper mapper = createObjectMapper(); + + public static ObjectMapper getMapper() { + return mapper; + } + + private static ObjectMapper createObjectMapper() { + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(new JavaTimeModule()); + return mapper; + } +} diff --git a/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/impl/model/IdentityDocument.java b/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/impl/model/IdentityDocument.java new file mode 100644 index 00000000000..41ce5d969a7 --- /dev/null +++ b/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/impl/model/IdentityDocument.java @@ -0,0 +1,59 @@ +// 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.athenz.instanceproviderservice.impl.model; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.time.Instant; +import java.util.Objects; + +/** + * @author bjorncs + */ +public class IdentityDocument { + + @JsonProperty("provider-unique-id") + public final ProviderUniqueId providerUniqueId; + @JsonProperty("configserver-hostname") + public final String configServerHostname; + @JsonProperty("instance-hostname") + public final String instanceHostname; + @JsonProperty("created-at") + public final Instant createdAt; + + public IdentityDocument( + @JsonProperty("provider-unique-id") ProviderUniqueId providerUniqueId, + @JsonProperty("configserver-hostname") String configServerHostname, + @JsonProperty("instance-hostname") String instanceHostname, + @JsonProperty("created-at") Instant createdAt) { + this.providerUniqueId = providerUniqueId; + this.configServerHostname = configServerHostname; + this.instanceHostname = instanceHostname; + this.createdAt = createdAt; + } + + @Override + public String toString() { + return "IdentityDocument{" + + "providerUniqueId=" + providerUniqueId + + ", configServerHostname='" + configServerHostname + '\'' + + ", instanceHostname='" + instanceHostname + '\'' + + ", createdAt=" + createdAt + + '}'; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + IdentityDocument that = (IdentityDocument) o; + return Objects.equals(providerUniqueId, that.providerUniqueId) && + Objects.equals(configServerHostname, that.configServerHostname) && + Objects.equals(instanceHostname, that.instanceHostname) && + Objects.equals(createdAt, that.createdAt); + } + + @Override + public int hashCode() { + return Objects.hash(providerUniqueId, configServerHostname, instanceHostname, createdAt); + } +} diff --git a/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/impl/model/InstanceConfirmation.java b/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/impl/model/InstanceConfirmation.java new file mode 100644 index 00000000000..ade42968e58 --- /dev/null +++ b/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/impl/model/InstanceConfirmation.java @@ -0,0 +1,99 @@ +// 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.athenz.instanceproviderservice.impl.model; + +import com.fasterxml.jackson.annotation.JsonAnySetter; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonUnwrapped; +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.yahoo.vespa.hosted.athenz.instanceproviderservice.impl.Utils; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + +/** + * InstanceConfirmation object as per Athenz InstanceConfirmation API. + * + * @author bjorncs + */ +public class InstanceConfirmation { + + @JsonProperty("provider") public final String provider; + @JsonProperty("domain") public final String domain; + @JsonProperty("service") public final String service; + + @JsonProperty("attestationData") @JsonSerialize(using = SignedIdentitySerializer.class) + public final SignedIdentityDocument signedIdentityDocument; + @JsonUnwrapped public final Map<String, Object> attributes = new HashMap<>(); // optional attributes that Athenz may provide + + @JsonCreator + public InstanceConfirmation(@JsonProperty("provider") String provider, + @JsonProperty("domain") String domain, + @JsonProperty("service") String service, + @JsonProperty("attestationData") @JsonDeserialize(using = SignedIdentityDeserializer.class) + SignedIdentityDocument signedIdentityDocument) { + this.provider = provider; + this.domain = domain; + this.service = service; + this.signedIdentityDocument = signedIdentityDocument; + } + + @JsonAnySetter + public void set(String name, Object value) { + attributes.put(name, value); + } + + @Override + public String toString() { + return "InstanceConfirmation{" + + "provider='" + provider + '\'' + + ", domain='" + domain + '\'' + + ", service='" + service + '\'' + + ", signedIdentityDocument='" + signedIdentityDocument + '\'' + + ", attributes=" + attributes + + '}'; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + InstanceConfirmation that = (InstanceConfirmation) o; + return Objects.equals(provider, that.provider) && + Objects.equals(domain, that.domain) && + Objects.equals(service, that.service) && + Objects.equals(signedIdentityDocument, that.signedIdentityDocument) && + Objects.equals(attributes, that.attributes); + } + + @Override + public int hashCode() { + return Objects.hash(provider, domain, service, signedIdentityDocument, attributes); + } + + public static class SignedIdentityDeserializer extends JsonDeserializer<SignedIdentityDocument> { + @Override + public SignedIdentityDocument deserialize( + JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException { + String value = jsonParser.getValueAsString(); + return Utils.getMapper().readValue(value, SignedIdentityDocument.class); + } + } + + public static class SignedIdentitySerializer extends JsonSerializer<SignedIdentityDocument> { + @Override + public void serialize( + SignedIdentityDocument document, JsonGenerator gen, SerializerProvider serializers) throws IOException { + gen.writeString(Utils.getMapper().writeValueAsString(document)); + } + } +} diff --git a/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/impl/model/ProviderUniqueId.java b/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/impl/model/ProviderUniqueId.java new file mode 100644 index 00000000000..810c75ef0c5 --- /dev/null +++ b/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/impl/model/ProviderUniqueId.java @@ -0,0 +1,79 @@ +// 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.athenz.instanceproviderservice.impl.model; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.Objects; + +/** + * @author bjorncs + */ +public class ProviderUniqueId { + + @JsonProperty("tenant") + public final String tenant; + @JsonProperty("application") + public final String application; + @JsonProperty("environment") + public final String environment; + @JsonProperty("region") + public final String region; + @JsonProperty("instance") + public final String instance; + @JsonProperty("cluster-id") + public final String clusterId; + @JsonProperty("cluster-index") + public final int clusterIndex; + + public ProviderUniqueId(@JsonProperty("tenant") String tenant, + @JsonProperty("application") String application, + @JsonProperty("environment") String environment, + @JsonProperty("region") String region, + @JsonProperty("instance") String instance, + @JsonProperty("cluster-id") String clusterId, + @JsonProperty("cluster-index") int clusterIndex) { + this.tenant = tenant; + this.application = application; + this.environment = environment; + this.region = region; + this.instance = instance; + this.clusterId = clusterId; + this.clusterIndex = clusterIndex; + } + + public String asString() { + return String.format("%s.%s.%s.%s.%s.%s.%d", tenant, application, environment, region, instance, clusterId, clusterIndex); + } + + @Override + public String toString() { + return "ProviderUniqueId{" + + "tenant='" + tenant + '\'' + + ", application='" + application + '\'' + + ", environment='" + environment + '\'' + + ", region='" + region + '\'' + + ", instance='" + instance + '\'' + + ", clusterId='" + clusterId + '\'' + + ", clusterIndex=" + clusterIndex + + '}'; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + ProviderUniqueId that = (ProviderUniqueId) o; + return clusterIndex == that.clusterIndex && + Objects.equals(tenant, that.tenant) && + Objects.equals(application, that.application) && + Objects.equals(environment, that.environment) && + Objects.equals(region, that.region) && + Objects.equals(instance, that.instance) && + Objects.equals(clusterId, that.clusterId); + } + + @Override + public int hashCode() { + return Objects.hash(tenant, application, environment, region, instance, clusterId, clusterIndex); + } +}
\ No newline at end of file diff --git a/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/impl/model/SignedIdentityDocument.java b/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/impl/model/SignedIdentityDocument.java new file mode 100644 index 00000000000..37f94d48a95 --- /dev/null +++ b/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/impl/model/SignedIdentityDocument.java @@ -0,0 +1,87 @@ +// 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.athenz.instanceproviderservice.impl.model; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.yahoo.vespa.hosted.athenz.instanceproviderservice.impl.Utils; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.util.Base64; +import java.util.Objects; + +/** + * @author bjorncs + */ +public class SignedIdentityDocument { + + public static final int DEFAULT_KEY_VERSION = 0; + public static final int DEFAILT_DOCUMENT_VERSION = 1; + + @JsonProperty("identity-document")public final String rawIdentityDocument; + @JsonIgnore public final IdentityDocument identityDocument; + @JsonProperty("signature") public final String signature; + @JsonProperty("signing-key-version") public final int signingKeyVersion; + @JsonProperty("provider-unique-id") public final String providerUniqueId; // String representation + @JsonProperty("dns-suffix") public final String dnsSuffix; + @JsonProperty("provider-service") public final String providerService; + @JsonProperty("zts-endpoint") public final String ztsEndpoint; + @JsonProperty("document-version") public final int documentVersion; + + @JsonCreator + public SignedIdentityDocument(@JsonProperty("identity-document") String rawIdentityDocument, + @JsonProperty("signature") String signature, + @JsonProperty("signing-key-version") int signingKeyVersion, + @JsonProperty("provider-unique-id") String providerUniqueId, + @JsonProperty("dns-suffix") String dnsSuffix, + @JsonProperty("provider-service") String providerService, + @JsonProperty("zts-endpoint") String ztsEndpoint, + @JsonProperty("document-version") int documentVersion) { + this.rawIdentityDocument = rawIdentityDocument; + this.identityDocument = parseIdentityDocument(rawIdentityDocument); + this.signature = signature; + this.signingKeyVersion = signingKeyVersion; + this.providerUniqueId = providerUniqueId; + this.dnsSuffix = dnsSuffix; + this.providerService = providerService; + this.ztsEndpoint = ztsEndpoint; + this.documentVersion = documentVersion; + } + + private static IdentityDocument parseIdentityDocument(String rawIdentityDocument) { + try { + return Utils.getMapper().readValue(Base64.getDecoder().decode(rawIdentityDocument), IdentityDocument.class); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + @Override + public String toString() { + return "SignedIdentityDocument{" + + "rawIdentityDocument='" + rawIdentityDocument + '\'' + + ", identityDocument=" + identityDocument + + ", signature='" + signature + '\'' + + ", signingKeyVersion=" + signingKeyVersion + + ", documentVersion=" + documentVersion + + '}'; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + SignedIdentityDocument that = (SignedIdentityDocument) o; + return signingKeyVersion == that.signingKeyVersion && + documentVersion == that.documentVersion && + Objects.equals(rawIdentityDocument, that.rawIdentityDocument) && + Objects.equals(identityDocument, that.identityDocument) && + Objects.equals(signature, that.signature); + } + + @Override + public int hashCode() { + return Objects.hash(rawIdentityDocument, identityDocument, signature, signingKeyVersion, documentVersion); + } +} diff --git a/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/package-info.java b/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/package-info.java new file mode 100644 index 00000000000..3024d1e0115 --- /dev/null +++ b/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/package-info.java @@ -0,0 +1,8 @@ +// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +/** + * @author bjorncs + */ +@ExportPackage +package com.yahoo.vespa.hosted.athenz.instanceproviderservice; + +import com.yahoo.osgi.annotation.ExportPackage; diff --git a/athenz-identity-provider-service/src/main/resources/configdefinitions/athenz-provider-service.def b/athenz-identity-provider-service/src/main/resources/configdefinitions/athenz-provider-service.def new file mode 100644 index 00000000000..3a2ef9c3092 --- /dev/null +++ b/athenz-identity-provider-service/src/main/resources/configdefinitions/athenz-provider-service.def @@ -0,0 +1,29 @@ +# Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +namespace=vespa.hosted.athenz.identityproviderservice.config + +# Athenz domain +domain string + +# Athenz service name +serviceName string + +# Current key version +keyVersion int default=0 + +# HTTPS port for Athenz Provider Service endpoint +port int default=8443 + +# File name prefix for private and public key. Component assumes suffix .[priv|pub].<version>. +keyPathPrefix string + +# InstanceConfirmation API path +apiPath string default="/athenz/v1/provider/instance" + +# Athenz principal authority header name +athenzPrincipalHeaderName string default="Athenz-Principal-Auth" + +# Athenz ZTS server url +ztsUrl string + +# Certificate DNS suffix +certDnsSuffix string diff --git a/athenz-identity-provider-service/src/test/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/AthenzInstanceProviderServiceTest.java b/athenz-identity-provider-service/src/test/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/AthenzInstanceProviderServiceTest.java new file mode 100644 index 00000000000..125f8a3cb0f --- /dev/null +++ b/athenz-identity-provider-service/src/test/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/AthenzInstanceProviderServiceTest.java @@ -0,0 +1,260 @@ +// 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.athenz.instanceproviderservice; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.common.collect.ImmutableSet; +import com.yahoo.athenz.auth.util.Crypto; +import com.yahoo.component.Version; +import com.yahoo.config.provision.ApplicationId; +import com.yahoo.config.provision.ApplicationName; +import com.yahoo.config.provision.ClusterMembership; +import com.yahoo.config.provision.Environment; +import com.yahoo.config.provision.Flavor; +import com.yahoo.config.provision.InstanceName; +import com.yahoo.config.provision.NodeType; +import com.yahoo.config.provision.RegionName; +import com.yahoo.config.provision.TenantName; +import com.yahoo.config.provision.Zone; +import com.yahoo.log.LogLevel; +import com.yahoo.vespa.hosted.athenz.identityproviderservice.config.AthenzProviderServiceConfig; +import com.yahoo.vespa.hosted.athenz.instanceproviderservice.impl.IdentityDocumentGenerator; +import com.yahoo.vespa.hosted.athenz.instanceproviderservice.impl.InstanceValidator; +import com.yahoo.vespa.hosted.athenz.instanceproviderservice.impl.KeyProvider; +import com.yahoo.vespa.hosted.athenz.instanceproviderservice.impl.Utils; +import com.yahoo.vespa.hosted.athenz.instanceproviderservice.impl.model.IdentityDocument; +import com.yahoo.vespa.hosted.athenz.instanceproviderservice.impl.model.InstanceConfirmation; +import com.yahoo.vespa.hosted.athenz.instanceproviderservice.impl.model.ProviderUniqueId; +import com.yahoo.vespa.hosted.athenz.instanceproviderservice.impl.model.SignedIdentityDocument; +import com.yahoo.vespa.hosted.provision.Node; +import com.yahoo.vespa.hosted.provision.NodeRepository; +import com.yahoo.vespa.hosted.provision.node.Allocation; +import com.yahoo.vespa.hosted.provision.node.Generation; +import com.yahoo.vespa.hosted.provision.testutils.MockNodeFlavors; +import org.apache.http.HttpEntity; +import org.apache.http.HttpResponse; +import org.apache.http.HttpStatus; +import org.apache.http.client.HttpClient; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.conn.ssl.NoopHostnameVerifier; +import org.apache.http.entity.StringEntity; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClients; +import org.apache.http.ssl.SSLContextBuilder; +import org.bouncycastle.openssl.jcajce.JcaPEMWriter; +import org.junit.Ignore; +import org.junit.Test; + +import javax.net.ssl.SSLContext; +import java.io.IOException; +import java.io.StringWriter; +import java.io.UnsupportedEncodingException; +import java.security.InvalidKeyException; +import java.security.Key; +import java.security.KeyManagementException; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.security.Signature; +import java.security.SignatureException; +import java.time.Instant; +import java.util.Base64; +import java.util.HashSet; +import java.util.Optional; +import java.util.logging.Logger; + +import static org.hamcrest.CoreMatchers.equalTo; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThat; +import static org.junit.Assert.assertTrue; +import static org.mockito.Matchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * @author bjorncs + */ +public class AthenzInstanceProviderServiceTest { + + private static final Logger log = Logger.getLogger(AthenzInstanceProviderServiceTest.class.getName()); + private static final int PORT = 12345; + + @Test + @Ignore("Requires private key for Athenz service") + public void provider_service_hosts_endpoint_secured_with_tls() throws Exception { + String domain = "INSERT DOMAIN HERE"; + String service = "INSERT SERVICE NAME HERE"; + DummyKeyProvider keyProvider = new DummyKeyProvider(); + PrivateKey privateKey = Crypto.loadPrivateKey(keyProvider.getPrivateKey(0)); + + AthenzProviderServiceConfig config = getAthenzProviderConfig(domain, service, "INSERT ZTS URL HERE", "INSERT DNS SUFFIX HERE"); + + ScheduledExecutorServiceMock executor = new ScheduledExecutorServiceMock(); + NodeRepository nodeRepository = mock(NodeRepository.class); + Zone zone = new Zone(Environment.dev, RegionName.from("us-north-1")); + AthenzInstanceProviderService athenzInstanceProviderService = new AthenzInstanceProviderService(config, keyProvider, executor, nodeRepository, zone); + + try (CloseableHttpClient client = createHttpClient(domain, service)) { + Runnable certificateRefreshCommand = executor.getCommand().orElseThrow(() -> new AssertionError("Command not present")); + assertFalse(getStatus(client)); + certificateRefreshCommand.run(); + assertTrue(getStatus(client)); + assertInstanceConfirmationSucceeds(client, privateKey); + certificateRefreshCommand.run(); + assertTrue(getStatus(client)); + assertInstanceConfirmationSucceeds(client, privateKey); + } finally { + athenzInstanceProviderService.deconstruct(); + } + } + + @Test + public void generates_valid_identity_document() throws IOException, NoSuchAlgorithmException, KeyStoreException, KeyManagementException { + String hostname = "x.y.com"; + AutoGeneratedKeyProvider keyProvider = new AutoGeneratedKeyProvider(); + AthenzProviderServiceConfig config = getAthenzProviderConfig("domain", "service", "localhost/zts", "dnsSuffix"); + + NodeRepository nodeRepository = mock(NodeRepository.class); + MockNodeFlavors nodeFlavors = new MockNodeFlavors(); + ApplicationId appid = ApplicationId.from(TenantName.from("tenant"), ApplicationName.from("application"), InstanceName.from("default")); + Allocation allocation = new Allocation(appid, ClusterMembership.from("container/default/0/0", Version.fromString("1.2.3")), Generation.inital(), false); Flavor flavor = nodeFlavors.getFlavorOrThrow("default"); + Node n = Node.create("ostkid", ImmutableSet.of("127.0.0.1"), new HashSet<>(), hostname, Optional.empty(), flavor, NodeType.tenant).with(allocation); + when(nodeRepository.getNode(eq(hostname))).thenReturn(Optional.of(n)); + Zone zone = new Zone(Environment.dev, RegionName.from("us-north-1")); + + IdentityDocumentGenerator identityDocumentGenerator = new IdentityDocumentGenerator(config, nodeRepository, zone, keyProvider); + String rawSignedIdentityDocument = identityDocumentGenerator.generateSignedIdentityDocument(hostname); + + + SignedIdentityDocument signedIdentityDocument = Utils.getMapper().readValue(rawSignedIdentityDocument, SignedIdentityDocument.class); + + // Verify attributes + assertEquals(hostname, signedIdentityDocument.identityDocument.instanceHostname); + ProviderUniqueId expectedProviderUniqueId = new ProviderUniqueId("tenant", "application", "dev", "us-north-1", "default", "default", 0); + assertEquals(expectedProviderUniqueId, signedIdentityDocument.identityDocument.providerUniqueId); + + // Validate signature + assertTrue("Message", InstanceValidator.isSignatureValid(Crypto.loadPublicKey(keyProvider.getPublicKey(0)), signedIdentityDocument.rawIdentityDocument, signedIdentityDocument.signature)); + + } + + private AthenzProviderServiceConfig getAthenzProviderConfig(String domain, String service, String ztsUrl, String dnsSuffix) { + return new AthenzProviderServiceConfig( + new AthenzProviderServiceConfig.Builder() + .domain(domain) + .serviceName(service) + .port(PORT) + .keyPathPrefix("dummy-path") + .certDnsSuffix(dnsSuffix) + .ztsUrl(ztsUrl) + .athenzPrincipalHeaderName("INSERT PRINCIPAL HEADER NAME HERE") + .apiPath("/")); + + } + private static boolean getStatus(HttpClient client) { + try { + HttpResponse response = client.execute(new HttpGet("https://localhost:" + PORT + "/status.html")); + return response.getStatusLine().getStatusCode() == HttpStatus.SC_OK; + } catch (Exception e) { + log.log(LogLevel.INFO, "Status.html failed: " + e); + return false; + } + } + + private static void assertInstanceConfirmationSucceeds(HttpClient client, PrivateKey privateKey) throws IOException { + HttpPost httpPost = new HttpPost("https://localhost:" + PORT + "/"); + httpPost.setEntity(createInstanceConfirmation(privateKey)); + HttpResponse response = client.execute(httpPost); + assertThat(response.getStatusLine().getStatusCode(), equalTo(200)); + } + + private static CloseableHttpClient createHttpClient(String domain, String service) + throws NoSuchAlgorithmException, KeyManagementException, KeyStoreException { + SSLContext sslContext = new SSLContextBuilder() + .loadTrustMaterial(null, (certificateChain, ignoredAuthType) -> + certificateChain[0].getSubjectX500Principal().getName().equals("CN=" + domain + "." + service)) + .build(); + + return HttpClients.custom() + .setSslcontext(sslContext) + .setSSLHostnameVerifier(new NoopHostnameVerifier()) + .build(); + } + + private static HttpEntity createInstanceConfirmation(PrivateKey privateKey) { + IdentityDocument identityDocument = new IdentityDocument( + new ProviderUniqueId( + "tenant", "application", "environment", "region", "instance", "cluster-id", 0), + "hostname", "instance-hostname", Instant.now()); + try { + ObjectMapper mapper = Utils.getMapper(); + String encodedIdentityDocument = + Base64.getEncoder().encodeToString(mapper.writeValueAsString(identityDocument).getBytes()); + Signature sigGenerator = Signature.getInstance("SHA512withRSA"); + sigGenerator.initSign(privateKey); + sigGenerator.update(encodedIdentityDocument.getBytes()); + String signature = Base64.getEncoder().encodeToString(sigGenerator.sign()); + + InstanceConfirmation instanceConfirmation = new InstanceConfirmation( + "provider", "domain", "service", + new SignedIdentityDocument(encodedIdentityDocument, signature, 0, identityDocument.providerUniqueId.asString(), "dnssuffix", "service", "localhost/zts",1)); + return new StringEntity(mapper.writeValueAsString(instanceConfirmation)); + } catch (JsonProcessingException + | NoSuchAlgorithmException + | UnsupportedEncodingException + | SignatureException + | InvalidKeyException e) { + throw new RuntimeException(e); + } + } + + private static class DummyKeyProvider implements KeyProvider { + + @Override + public String getPrivateKey(int version) { + return "INSERT PRIV KEY"; + } + + @Override + public String getPublicKey(int version) { + return "INSERT PUB KEY"; + } + } + + private static class AutoGeneratedKeyProvider implements KeyProvider { + + private final String publicKey; + private final String privateKey; + + public AutoGeneratedKeyProvider() throws IOException, NoSuchAlgorithmException { + KeyPairGenerator rsa = KeyPairGenerator.getInstance("RSA"); + rsa.initialize(2048); + KeyPair keyPair = rsa.genKeyPair(); + publicKey = pemEncode("RSA PUBLIC KEY", keyPair.getPublic()); + privateKey = pemEncode("RSA PRIVATE KEY", keyPair.getPrivate()); + } + + private String pemEncode(String description, Key key) throws IOException { + StringWriter stringWriter = new StringWriter(); + JcaPEMWriter pemWriter = new JcaPEMWriter(stringWriter); + pemWriter.writeObject(key); + pemWriter.flush(); + return stringWriter.toString(); + + } + + @Override + public String getPrivateKey(int version) { + return privateKey; + } + + @Override + public String getPublicKey(int version) { + return publicKey; + } + } +} diff --git a/athenz-identity-provider-service/src/test/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/ScheduledExecutorServiceMock.java b/athenz-identity-provider-service/src/test/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/ScheduledExecutorServiceMock.java new file mode 100644 index 00000000000..45cb82a0c0a --- /dev/null +++ b/athenz-identity-provider-service/src/test/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/ScheduledExecutorServiceMock.java @@ -0,0 +1,115 @@ +// 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.athenz.instanceproviderservice; + +import java.util.Collection; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +/** + * @author bjorncs + */ +public class ScheduledExecutorServiceMock implements ScheduledExecutorService { + + private Runnable runnable; + + public Optional<Runnable> getCommand() { + return Optional.ofNullable(runnable); + } + + @Override + public ScheduledFuture<?> schedule(Runnable command, long delay, TimeUnit unit) { + throw new UnsupportedOperationException(); + } + + @Override + public <V> ScheduledFuture<V> schedule(Callable<V> callable, long delay, TimeUnit unit) { + throw new UnsupportedOperationException(); + } + + @Override + public ScheduledFuture<?> scheduleAtFixedRate(Runnable command, long initialDelay, long period, TimeUnit unit) { + if (runnable != null) { + throw new IllegalStateException("Can only register single command"); + } + runnable = Objects.requireNonNull(command); + return null; + } + + @Override + public ScheduledFuture<?> scheduleWithFixedDelay(Runnable command, long initialDelay, long delay, TimeUnit unit) { + throw new UnsupportedOperationException(); + } + + @Override + public void shutdown() { + // do nothing + } + + @Override + public List<Runnable> shutdownNow() { + throw new UnsupportedOperationException(); + } + + @Override + public boolean isShutdown() { + throw new UnsupportedOperationException(); + } + + @Override + public boolean isTerminated() { + throw new UnsupportedOperationException(); + } + + @Override + public boolean awaitTermination(long timeout, TimeUnit unit) throws InterruptedException { + return true; + } + + @Override + public <T> Future<T> submit(Callable<T> task) { + throw new UnsupportedOperationException(); + } + + @Override + public <T> Future<T> submit(Runnable task, T result) { + throw new UnsupportedOperationException(); + } + + @Override + public Future<?> submit(Runnable task) { + throw new UnsupportedOperationException(); + } + + @Override + public <T> List<Future<T>> invokeAll(Collection<? extends Callable<T>> tasks) throws InterruptedException { + throw new UnsupportedOperationException(); + } + + @Override + public <T> List<Future<T>> invokeAll(Collection<? extends Callable<T>> tasks, long timeout, TimeUnit unit) throws InterruptedException { + throw new UnsupportedOperationException(); + } + + @Override + public <T> T invokeAny(Collection<? extends Callable<T>> tasks) throws InterruptedException, ExecutionException { + throw new UnsupportedOperationException(); + } + + @Override + public <T> T invokeAny(Collection<? extends Callable<T>> tasks, long timeout, TimeUnit unit) throws InterruptedException, ExecutionException, TimeoutException { + throw new UnsupportedOperationException(); + } + + @Override + public void execute(Runnable command) { + throw new UnsupportedOperationException(); + } +} diff --git a/config-model/src/main/java/com/yahoo/vespa/model/container/Container.java b/config-model/src/main/java/com/yahoo/vespa/model/container/Container.java index 8e2c6b8532d..ddd998c8816 100644 --- a/config-model/src/main/java/com/yahoo/vespa/model/container/Container.java +++ b/config-model/src/main/java/com/yahoo/vespa/model/container/Container.java @@ -7,6 +7,7 @@ import com.yahoo.config.model.producer.AbstractConfigProducer; import com.yahoo.container.ComponentsConfig; import com.yahoo.container.QrConfig; import com.yahoo.container.core.ContainerHttpConfig; +import com.yahoo.container.core.identity.IdentityConfig; import com.yahoo.container.jdisc.ContainerMbusConfig; import com.yahoo.container.jdisc.JdiscBindingsConfig; import com.yahoo.search.config.QrStartConfig; @@ -48,7 +49,8 @@ public class Container extends AbstractService implements ComponentsConfig.Producer, JdiscBindingsConfig.Producer, ContainerHttpConfig.Producer, - ContainerMbusConfig.Producer { + ContainerMbusConfig.Producer, + IdentityConfig.Producer { public static final int BASEPORT = Defaults.getDefaults().vespaWebServicePort(); public static final String SINGLENODE_CONTAINER_SERVICESPEC = "default_singlenode_container"; @@ -394,6 +396,18 @@ public class Container extends AbstractService implements this.httpServerEnabled = httpServerEnabled; } + @Override + public void getConfig(IdentityConfig.Builder builder) { + if(parent instanceof ContainerCluster) { + ContainerCluster containerCluster = (ContainerCluster) parent; + Optional<Identity> identity = containerCluster.getIdentity(); + identity.ifPresent(id -> { + builder.serviceName(id.getService()); + builder.domain(id.getDomain()); + }); + } + } + public static final class PortOverride { public final ComponentSpecification serverId; public final int port; diff --git a/config-model/src/main/java/com/yahoo/vespa/model/container/ContainerCluster.java b/config-model/src/main/java/com/yahoo/vespa/model/container/ContainerCluster.java index 4eb611a1dbe..45a50af8f3b 100755 --- a/config-model/src/main/java/com/yahoo/vespa/model/container/ContainerCluster.java +++ b/config-model/src/main/java/com/yahoo/vespa/model/container/ContainerCluster.java @@ -7,11 +7,11 @@ import com.yahoo.cloud.config.RoutingProviderConfig; import com.yahoo.component.ComponentId; import com.yahoo.component.ComponentSpecification; import com.yahoo.config.FileReference; +import com.yahoo.config.application.api.ApplicationMetaData; +import com.yahoo.config.application.api.ComponentInfo; import com.yahoo.config.application.api.DeploymentSpec; import com.yahoo.config.docproc.DocprocConfig; import com.yahoo.config.docproc.SchemamappingConfig; -import com.yahoo.config.application.api.ApplicationMetaData; -import com.yahoo.config.application.api.ComponentInfo; import com.yahoo.config.model.ApplicationConfigProducerRoot; import com.yahoo.config.model.producer.AbstractConfigProducer; import com.yahoo.config.model.producer.AbstractConfigProducerRoot; @@ -65,8 +65,8 @@ import com.yahoo.vespa.model.container.component.ConfigProducerGroup; import com.yahoo.vespa.model.container.component.DiscBindingsConfigGenerator; import com.yahoo.vespa.model.container.component.FileStatusHandlerComponent; import com.yahoo.vespa.model.container.component.Handler; -import com.yahoo.vespa.model.container.component.SimpleComponent; import com.yahoo.vespa.model.container.component.Servlet; +import com.yahoo.vespa.model.container.component.SimpleComponent; import com.yahoo.vespa.model.container.component.StatisticsComponent; import com.yahoo.vespa.model.container.component.chain.ProcessingHandler; import com.yahoo.vespa.model.container.docproc.ContainerDocproc; @@ -82,7 +82,6 @@ import com.yahoo.vespa.model.content.Content; import com.yahoo.vespa.model.search.AbstractSearchCluster; import com.yahoo.vespa.model.utils.FileSender; import com.yahoo.vespaclient.config.FeederConfig; - import edu.umd.cs.findbugs.annotations.NonNull; import edu.umd.cs.findbugs.annotations.Nullable; @@ -187,6 +186,8 @@ public final class ContainerCluster private Optional<String> hostClusterId = Optional.empty(); private Optional<Integer> memoryPercentage = Optional.empty(); + private Identity identity; + private static class AcceptAllVerifier implements ContainerClusterVerifier { @Override public boolean acceptComponent(Component component) { return true; } @@ -238,6 +239,9 @@ public final class ContainerCluster public void setZone(Zone zone) { this.zone = zone; } + public Zone getZone() { + return zone; + } public void addMetricStateHandler() { Handler<AbstractConfigProducer<?>> stateHandler = new Handler<>( @@ -836,6 +840,15 @@ public final class ContainerCluster */ public Optional<Integer> getMemoryPercentage() { return memoryPercentage; } + public Optional<Identity> getIdentity() { + return Optional.ofNullable(identity); + } + + public void setIdentity(Identity identity) { + this.identity = identity; + addSimpleComponent("com.yahoo.container.jdisc.athenz.AthenzIdentityProvider"); + } + @Override public String toString() { return "container cluster '" + getName() + "'"; diff --git a/config-model/src/main/java/com/yahoo/vespa/model/container/Identity.java b/config-model/src/main/java/com/yahoo/vespa/model/container/Identity.java new file mode 100644 index 00000000000..e872cb7a73b --- /dev/null +++ b/config-model/src/main/java/com/yahoo/vespa/model/container/Identity.java @@ -0,0 +1,22 @@ +package com.yahoo.vespa.model.container; + +/** + * @author mortent + */ +public class Identity { + private final String domain; + private final String service; + + public Identity(String domain, String service) { + this.domain = domain; + this.service = service; + } + + public String getDomain() { + return domain; + } + + public String getService() { + return service; + } +} diff --git a/config-model/src/main/java/com/yahoo/vespa/model/container/configserver/ConfigserverCluster.java b/config-model/src/main/java/com/yahoo/vespa/model/container/configserver/ConfigserverCluster.java index c3361426d64..f7caa0c734c 100644 --- a/config-model/src/main/java/com/yahoo/vespa/model/container/configserver/ConfigserverCluster.java +++ b/config-model/src/main/java/com/yahoo/vespa/model/container/configserver/ConfigserverCluster.java @@ -141,6 +141,9 @@ public class ConfigserverCluster extends AbstractConfigProducer if (options.dockerVespaBaseImage().isPresent()) { builder.dockerVespaBaseImage(options.dockerVespaBaseImage().get()); } + if (options.serviceProviderEndpoint().isPresent()) { + builder.serviceProviderEndpoint(options.serviceProviderEndpoint().get()); + } } private String[] getConfigModelPluginDirs() { diff --git a/config-model/src/main/java/com/yahoo/vespa/model/container/configserver/option/CloudConfigOptions.java b/config-model/src/main/java/com/yahoo/vespa/model/container/configserver/option/CloudConfigOptions.java index 8acab12414d..ae29a912c11 100644 --- a/config-model/src/main/java/com/yahoo/vespa/model/container/configserver/option/CloudConfigOptions.java +++ b/config-model/src/main/java/com/yahoo/vespa/model/container/configserver/option/CloudConfigOptions.java @@ -43,4 +43,5 @@ public interface CloudConfigOptions { Optional<Integer> numParallelTenantLoaders(); Optional<String> dockerRegistry(); Optional<String> dockerVespaBaseImage(); + Optional<String> serviceProviderEndpoint(); } diff --git a/config-model/src/main/java/com/yahoo/vespa/model/container/xml/ContainerModelBuilder.java b/config-model/src/main/java/com/yahoo/vespa/model/container/xml/ContainerModelBuilder.java index 49229fcc87a..0d221b57748 100644 --- a/config-model/src/main/java/com/yahoo/vespa/model/container/xml/ContainerModelBuilder.java +++ b/config-model/src/main/java/com/yahoo/vespa/model/container/xml/ContainerModelBuilder.java @@ -4,16 +4,16 @@ package com.yahoo.vespa.model.container.xml; import com.google.common.collect.ImmutableList; import com.yahoo.component.Version; import com.yahoo.config.application.Xml; -import com.yahoo.config.model.ConfigModelContext; import com.yahoo.config.application.api.ApplicationPackage; import com.yahoo.config.application.api.DeployLogger; +import com.yahoo.config.model.ConfigModelContext; import com.yahoo.config.model.application.provider.IncludeDirs; import com.yahoo.config.model.builder.xml.ConfigModelBuilder; import com.yahoo.config.model.builder.xml.ConfigModelId; import com.yahoo.config.model.producer.AbstractConfigProducer; import com.yahoo.config.provision.Capacity; -import com.yahoo.config.provision.ClusterSpec; import com.yahoo.config.provision.ClusterMembership; +import com.yahoo.config.provision.ClusterSpec; import com.yahoo.config.provision.Environment; import com.yahoo.config.provision.NodeType; import com.yahoo.container.jdisc.config.MetricDefaultsConfig; @@ -22,14 +22,15 @@ import com.yahoo.text.XML; import com.yahoo.vespa.defaults.Defaults; import com.yahoo.vespa.model.AbstractService; import com.yahoo.vespa.model.HostResource; +import com.yahoo.vespa.model.container.Identity; import com.yahoo.vespa.model.builder.xml.dom.DomClientProviderBuilder; import com.yahoo.vespa.model.builder.xml.dom.DomComponentBuilder; import com.yahoo.vespa.model.builder.xml.dom.DomFilterBuilder; import com.yahoo.vespa.model.builder.xml.dom.DomHandlerBuilder; import com.yahoo.vespa.model.builder.xml.dom.ModelElement; import com.yahoo.vespa.model.builder.xml.dom.NodesSpecification; -import com.yahoo.vespa.model.builder.xml.dom.VespaDomBuilder; import com.yahoo.vespa.model.builder.xml.dom.ServletBuilder; +import com.yahoo.vespa.model.builder.xml.dom.VespaDomBuilder; import com.yahoo.vespa.model.builder.xml.dom.chains.docproc.DomDocprocChainsBuilder; import com.yahoo.vespa.model.builder.xml.dom.chains.processing.DomProcessingBuilder; import com.yahoo.vespa.model.builder.xml.dom.chains.search.DomSearchChainsBuilder; @@ -52,12 +53,15 @@ import com.yahoo.vespa.model.container.search.QueryProfiles; import com.yahoo.vespa.model.container.search.SemanticRules; import com.yahoo.vespa.model.container.search.searchchain.SearchChains; import com.yahoo.vespa.model.container.xml.document.DocumentFactoryBuilder; - import com.yahoo.vespa.model.content.StorageGroup; import org.w3c.dom.Element; import org.w3c.dom.Node; -import java.util.*; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; import java.util.function.Consumer; import java.util.stream.Collectors; @@ -108,7 +112,6 @@ public class ContainerModelBuilder extends ConfigModelBuilder<ContainerModel> { ContainerCluster cluster = createContainerCluster(spec, modelContext); addClusterContent(cluster, spec, modelContext); addBundlesForPlatformComponents(cluster); - model.setCluster(cluster); } @@ -157,6 +160,9 @@ public class ContainerModelBuilder extends ConfigModelBuilder<ContainerModel> { addServerProviders(spec, cluster); addLegacyFilters(spec, cluster); // TODO: Remove for Vespa 7 + // Athenz copper argos + addIdentity(spec, cluster); + //TODO: overview handler, see DomQrserverClusterBuilder } @@ -683,6 +689,16 @@ public class ContainerModelBuilder extends ConfigModelBuilder<ContainerModel> { } } + private void addIdentity(Element element, ContainerCluster cluster) { + Element identityElement = XML.getChild(element, "identity"); + if(identityElement != null) { + String domain = XML.getValue(XML.getChild(identityElement, "domain")); + String service = XML.getValue(XML.getChild(identityElement, "service")); + Identity identity = new Identity(domain.trim(), service.trim()); + cluster.setIdentity(identity); + } + } + /** * Disallow renderers named "DefaultRenderer" or "JsonRenderer" */ diff --git a/config-model/src/main/resources/schema/containercluster.rnc b/config-model/src/main/resources/schema/containercluster.rnc index 85219bdb46a..af9b89b8553 100644 --- a/config-model/src/main/resources/schema/containercluster.rnc +++ b/config-model/src/main/resources/schema/containercluster.rnc @@ -7,7 +7,8 @@ ContainerCluster = element container | jdisc { ContainerServices & DocumentBinding* & Aliases? & - NodesOfContainerCluster? + NodesOfContainerCluster? & + Identity? } ContainerServices = @@ -225,3 +226,7 @@ DocumentBinding = element document { attribute bundle { xsd:NCName } } +Identity = element identity { + element domain { xsd:NCName } & + element service { xsd:NCName } +} diff --git a/config-model/src/test/java/com/yahoo/vespa/model/admin/DedicatedAdminV4Test.java b/config-model/src/test/java/com/yahoo/vespa/model/admin/DedicatedAdminV4Test.java index e2ed57251d9..4c09fd8d713 100644 --- a/config-model/src/test/java/com/yahoo/vespa/model/admin/DedicatedAdminV4Test.java +++ b/config-model/src/test/java/com/yahoo/vespa/model/admin/DedicatedAdminV4Test.java @@ -62,6 +62,10 @@ public class DedicatedAdminV4Test { " <metric name='nginx.upstreams.down.last' output-name='nginx.upstreams.down'/>" + " </consumer>" + " </metric-consumers>" + + " <identity>" + + " <domain>mydomain</domain>" + + " <service>myservice</service>" + + " </identity>" + " </admin>" + "</services>"; diff --git a/config-model/src/test/java/com/yahoo/vespa/model/container/configserver/TestOptions.java b/config-model/src/test/java/com/yahoo/vespa/model/container/configserver/TestOptions.java index 663a656347d..fe5cd3dc029 100644 --- a/config-model/src/test/java/com/yahoo/vespa/model/container/configserver/TestOptions.java +++ b/config-model/src/test/java/com/yahoo/vespa/model/container/configserver/TestOptions.java @@ -115,6 +115,9 @@ public class TestOptions implements CloudConfigOptions { @Override public Optional<String> dockerVespaBaseImage() { return Optional.empty(); } + @Override + public Optional<String> serviceProviderEndpoint() { return Optional.empty(); } + public TestOptions numParallelTenantLoaders(int numLoaders) { this.numParallelTenantLoaders = Optional.of(numLoaders); return this; diff --git a/config-model/src/test/schema-test-files/services.xml b/config-model/src/test/schema-test-files/services.xml index bf2a924e62d..380ce7f5a3d 100644 --- a/config-model/src/test/schema-test-files/services.xml +++ b/config-model/src/test/schema-test-files/services.xml @@ -36,6 +36,10 @@ </config> <jdisc id='qrsCluster_1' version='1.0'> + <identity> + <domain>mydomain</domain> + <service>myservice</service> + </identity> <rest-api path="jersey1"> <components bundle="my-bundle" /> <components bundle="other-bundle"> diff --git a/configdefinitions/src/vespa/configserver.def b/configdefinitions/src/vespa/configserver.def index e3997805c3e..fe71e33bb03 100644 --- a/configdefinitions/src/vespa/configserver.def +++ b/configdefinitions/src/vespa/configserver.def @@ -41,3 +41,6 @@ useVespaVersionInRequest bool default=false # Docker config dockerRegistry string default="" dockerVespaBaseImage string default="" + +# Athenz config +serviceProviderEndpoint string default=""
\ No newline at end of file diff --git a/configserver/src/main/resources/configserver-app/services.xml b/configserver/src/main/resources/configserver-app/services.xml index 7aec27a96f5..db95cb17530 100644 --- a/configserver/src/main/resources/configserver-app/services.xml +++ b/configserver/src/main/resources/configserver-app/services.xml @@ -134,5 +134,7 @@ <server port="19071" id="configserver" /> <preprocess:include file='hosted-vespa/http-server.xml' required='false' /> </http> + + <preprocess:include file='athenz-identity-provider.xml' required='false' /> </jdisc> </services> diff --git a/container-core/src/main/java/com/yahoo/container/core/identity/package-info.java b/container-core/src/main/java/com/yahoo/container/core/identity/package-info.java new file mode 100644 index 00000000000..f0d45f003c2 --- /dev/null +++ b/container-core/src/main/java/com/yahoo/container/core/identity/package-info.java @@ -0,0 +1,7 @@ +/** + * @author mortent + */ +@ExportPackage +package com.yahoo.container.core.identity; + +import com.yahoo.osgi.annotation.ExportPackage;
\ No newline at end of file diff --git a/container-core/src/main/resources/configdefinitions/identity.def b/container-core/src/main/resources/configdefinitions/identity.def new file mode 100644 index 00000000000..0f5080632b2 --- /dev/null +++ b/container-core/src/main/resources/configdefinitions/identity.def @@ -0,0 +1,4 @@ +namespace=container.core.identity + +domain string +serviceName string diff --git a/container-dev/pom.xml b/container-dev/pom.xml index 8bb759bf867..c6cbeacd2c0 100644 --- a/container-dev/pom.xml +++ b/container-dev/pom.xml @@ -111,6 +111,14 @@ <groupId>org.scala-lang</groupId> <artifactId>scala-library</artifactId> </exclusion> + <exclusion> + <groupId>org.apache.httpcomponents</groupId> + <artifactId>httpclient</artifactId> + </exclusion> + <exclusion> + <groupId>com.yahoo.athenz</groupId> + <artifactId>athenz-auth-core</artifactId> + </exclusion> </exclusions> </dependency> <dependency> diff --git a/container-disc/pom.xml b/container-disc/pom.xml index 44afe74459d..5409d9b1a40 100644 --- a/container-disc/pom.xml +++ b/container-disc/pom.xml @@ -130,6 +130,17 @@ <scope>provided</scope> </dependency> <!-- end WARNING --> + <!-- Athenz --> + <dependency> + <groupId>org.apache.httpcomponents</groupId> + <artifactId>httpclient</artifactId> + <scope>compile</scope> + </dependency> + <dependency> + <groupId>com.yahoo.athenz</groupId> + <artifactId>athenz-auth-core</artifactId> + <version>${athenz.version}</version> + </dependency> </dependencies> <properties> <!-- These versions must be the ones used by the current jersey version (see vespa_jersey2/target/dependency). diff --git a/container-disc/src/main/java/com/yahoo/container/jdisc/athenz/AthenzIdentityProvider.java b/container-disc/src/main/java/com/yahoo/container/jdisc/athenz/AthenzIdentityProvider.java new file mode 100644 index 00000000000..f67afdfc458 --- /dev/null +++ b/container-disc/src/main/java/com/yahoo/container/jdisc/athenz/AthenzIdentityProvider.java @@ -0,0 +1,121 @@ +package com.yahoo.container.jdisc.athenz; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.inject.Inject; +import com.yahoo.athenz.auth.util.Crypto; +import com.yahoo.cloud.config.ConfigserverConfig; +import com.yahoo.component.AbstractComponent; +import com.yahoo.container.core.identity.IdentityConfig; +import org.bouncycastle.asn1.x509.GeneralName; +import org.bouncycastle.operator.OperatorCreationException; + +import java.io.IOException; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.NoSuchAlgorithmException; + +/** + * @author mortent + */ +public final class AthenzIdentityProvider extends AbstractComponent { + + private InstanceIdentity instanceIdentity; + + private final String athenzUrl; + + private final String dnsSuffix; + private final String providerUniqueId; + private final String providerServiceName; + + @Inject + public AthenzIdentityProvider(IdentityConfig config, ConfigserverConfig configserverConfig) throws IOException { + this(config, new ServiceProviderApi(configserverConfig.serviceProviderEndpoint()), new AthenzService()); + } + + // Test only + public AthenzIdentityProvider(IdentityConfig config, ServiceProviderApi serviceProviderApi, AthenzService athenzService) throws IOException { + KeyPair keyPair = createKeyPair(); + String signedIdentityDocument = serviceProviderApi.getSignedIdentityDocument(); + this.athenzUrl = getZtsEndpoint(signedIdentityDocument); + dnsSuffix = getDnsSuffix(signedIdentityDocument); + providerUniqueId = getProviderUniqueId(signedIdentityDocument); + providerServiceName = getProviderServiceName(signedIdentityDocument); + + InstanceRegisterInformation instanceRegisterInformation = new InstanceRegisterInformation( + providerServiceName, + config.domain(), + config.serviceName(), + signedIdentityDocument, + createCSR(keyPair, config), + true + ); + instanceIdentity = athenzService.sendInstanceRegisterRequest(instanceRegisterInformation, athenzUrl); + } + + private String getProviderUniqueId(String signedIdentityDocument) throws IOException { + return getJsonNode(signedIdentityDocument, "provider-unique-id"); + } + + private String getDnsSuffix(String signedIdentityDocument) throws IOException { + return getJsonNode(signedIdentityDocument, "dns-suffix"); + } + + private String getProviderServiceName(String signedIdentityDocument) throws IOException { + return getJsonNode(signedIdentityDocument, "provider-service"); + } + + private String getZtsEndpoint(String signedIdentityDocument) throws IOException { + return getJsonNode(signedIdentityDocument, "zts-endpoint"); + } + + private String getJsonNode(String jsonString, String path) throws IOException { + ObjectMapper mapper = new ObjectMapper(); + JsonNode jsonNode = mapper.readTree(jsonString); + return jsonNode.get(path).asText(); + } + + private KeyPair createKeyPair() { + try { + KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA"); + return kpg.generateKeyPair(); + } catch (NoSuchAlgorithmException e) { + e.printStackTrace(); + throw new RuntimeException(e); + } + } + + private String createCSR(KeyPair keyPair, IdentityConfig identityConfig) throws IOException { + + try { + // Add SAN dnsname <service>.<domain-with-dashes>.<provider-dnsname-suffix> + // and SAN dnsname <provider-unique-instance-id>.instanceid.athenz.<provider-dnsname-suffix> + GeneralName[] sanDnsNames = new GeneralName[]{ + new GeneralName(GeneralName.dNSName, String.format("%s.%s.%s", + identityConfig.serviceName(), + identityConfig.domain().replace(".", "-"), + dnsSuffix)), + new GeneralName(GeneralName.dNSName, String.format("%s.instanceid.athenz.%s", + providerUniqueId, + dnsSuffix)) + }; + + return Crypto.generateX509CSR(keyPair.getPrivate(), + keyPair.getPublic(), + String.format("CN=%s.%s", identityConfig.domain(), identityConfig.serviceName()), + sanDnsNames); + } catch (OperatorCreationException e) { + e.printStackTrace(); + throw new RuntimeException(e); + } + } + + public String getNToken() { + return instanceIdentity.getServiceToken(); + } + + public String getX509Cert() { + return instanceIdentity.getX509Certificate(); + } +} + diff --git a/container-disc/src/main/java/com/yahoo/container/jdisc/athenz/AthenzService.java b/container-disc/src/main/java/com/yahoo/container/jdisc/athenz/AthenzService.java new file mode 100644 index 00000000000..cc5fa6a889b --- /dev/null +++ b/container-disc/src/main/java/com/yahoo/container/jdisc/athenz/AthenzService.java @@ -0,0 +1,46 @@ +package com.yahoo.container.jdisc.athenz; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpUriRequest; +import org.apache.http.client.methods.RequestBuilder; +import org.apache.http.entity.ContentType; +import org.apache.http.entity.StringEntity; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClientBuilder; +import org.apache.http.util.EntityUtils; +import org.eclipse.jetty.http.HttpStatus; + +import java.io.IOException; + +/** + * @author mortent + */ +public class AthenzService { + + /** + * Send instance register request to ZTS, get InstanceIdentity + * + * @param instanceRegisterInformation + */ + InstanceIdentity sendInstanceRegisterRequest(InstanceRegisterInformation instanceRegisterInformation, String athenzUrl) { + try(CloseableHttpClient client = HttpClientBuilder.create().build()) { + ObjectMapper objectMapper = new ObjectMapper(); + System.out.println(objectMapper.writeValueAsString(instanceRegisterInformation)); + HttpUriRequest postRequest = RequestBuilder.post() + .setUri(athenzUrl + "/instance") + .setEntity(new StringEntity(objectMapper.writeValueAsString(instanceRegisterInformation), ContentType.APPLICATION_JSON)) + .build(); + CloseableHttpResponse response = client.execute(postRequest); + if(HttpStatus.isSuccess(response.getStatusLine().getStatusCode())) { + return objectMapper.readValue(response.getEntity().getContent(), InstanceIdentity.class); + } else { + String s = EntityUtils.toString(response.getEntity()); + System.out.println("s = " + s); + throw new RuntimeException(response.toString()); + } + } catch (IOException e) { + throw new RuntimeException(e); + } + } +} diff --git a/container-disc/src/main/java/com/yahoo/container/jdisc/athenz/InstanceIdentity.java b/container-disc/src/main/java/com/yahoo/container/jdisc/athenz/InstanceIdentity.java new file mode 100644 index 00000000000..45ef4c68d8e --- /dev/null +++ b/container-disc/src/main/java/com/yahoo/container/jdisc/athenz/InstanceIdentity.java @@ -0,0 +1,55 @@ +package com.yahoo.container.jdisc.athenz; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.Map; + +/** + * Used for deserializing response from ZTS + * + * @author mortent + */ +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonInclude(JsonInclude.Include.NON_NULL) +class InstanceIdentity { + @JsonProperty("attributes") private final Map<String, String> attributes; + @JsonProperty("provider") private final String provider; + @JsonProperty("name") private final String name; + @JsonProperty("instanceId") private final String instanceId; + @JsonProperty("x509Certificate") private final String x509Certificate; + @JsonProperty("x509CertificateSigner") private final String x509CertificateSigner; + @JsonProperty("sshCertificate") private final String sshCertificate; + @JsonProperty("sshCertificateSigner") private final String sshCertificateSigner; + @JsonProperty("serviceToken") private final String serviceToken; + + public InstanceIdentity( + @JsonProperty("attributes") Map<String, String> attributes, + @JsonProperty("provider") String provider, + @JsonProperty("name") String name, + @JsonProperty("instanceId") String instanceId, + @JsonProperty("x509Certificate") String x509Certificate, + @JsonProperty("x509CertificateSigner") String x509CertificateSigner, + @JsonProperty("sshCertificate") String sshCertificate, + @JsonProperty("sshCertificateSigner") String sshCertificateSigner, + @JsonProperty("serviceToken") String serviceToken) { + this.attributes = attributes; + this.provider = provider; + this.name = name; + this.instanceId = instanceId; + this.x509Certificate = x509Certificate; + this.x509CertificateSigner = x509CertificateSigner; + this.sshCertificate = sshCertificate; + this.sshCertificateSigner = sshCertificateSigner; + this.serviceToken = serviceToken; + } + + String getX509Certificate() { + return x509Certificate; + } + + String getServiceToken() { + return serviceToken; + } +} diff --git a/container-disc/src/main/java/com/yahoo/container/jdisc/athenz/InstanceRegisterInformation.java b/container-disc/src/main/java/com/yahoo/container/jdisc/athenz/InstanceRegisterInformation.java new file mode 100644 index 00000000000..2fa3c598c58 --- /dev/null +++ b/container-disc/src/main/java/com/yahoo/container/jdisc/athenz/InstanceRegisterInformation.java @@ -0,0 +1,38 @@ +package com.yahoo.container.jdisc.athenz; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Used for serializing request to ZTS + * + * @author mortent + */ +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonInclude(JsonInclude.Include.NON_NULL) +class InstanceRegisterInformation { + @JsonProperty("provider") + private final String provider; + @JsonProperty("domain") + private final String domain; + @JsonProperty("service") + private final String service; + @JsonProperty("attestationData") + private final String attestationData; + @JsonProperty("ssh") + private final String ssh = null; // Not needed + @JsonProperty("csr") + private final String csr; + @JsonProperty("token") + private final boolean token; + + public InstanceRegisterInformation(String provider, String domain, String service, String attestationData, String csr, boolean token) { + this.provider = provider; + this.domain = domain; + this.service = service; + this.attestationData = attestationData; + this.csr = csr; + this.token = token; + } +} diff --git a/container-disc/src/main/java/com/yahoo/container/jdisc/athenz/ServiceProviderApi.java b/container-disc/src/main/java/com/yahoo/container/jdisc/athenz/ServiceProviderApi.java new file mode 100644 index 00000000000..0b417a4d440 --- /dev/null +++ b/container-disc/src/main/java/com/yahoo/container/jdisc/athenz/ServiceProviderApi.java @@ -0,0 +1,47 @@ +package com.yahoo.container.jdisc.athenz; + +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.RequestBuilder; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClientBuilder; +import org.apache.http.util.EntityUtils; +import org.eclipse.jetty.http.HttpStatus; + +import java.io.IOException; + +/** + * @author mortent + */ +public class ServiceProviderApi { + + private final String providerEndpoint; + + public ServiceProviderApi(String providerEndpoint) { + this.providerEndpoint = providerEndpoint; + } + + + /** + * Get signed identity document from config server + * + * @return + */ + String getSignedIdentityDocument() { + + // TODO Use client side auth to establish trusted secure channel + try (CloseableHttpClient httpClient = HttpClientBuilder.create().build()) { + + CloseableHttpResponse idDocResponse = httpClient.execute(RequestBuilder.get().setUri(providerEndpoint + "/identity-document").build()); + if (HttpStatus.isSuccess(idDocResponse.getStatusLine().getStatusCode())) { + return EntityUtils.toString(idDocResponse.getEntity()); + } else { + // make sure we have retried a few times (AND logged) before giving up + throw new RuntimeException("Failed to initialize Athenz instance provider"); + } + } catch (IOException e) { + e.printStackTrace(); + throw new RuntimeException(e); + } + } + +} diff --git a/container-disc/src/test/java/com/yahoo/container/jdisc/athenz/AthenzIdentityProviderTest.java b/container-disc/src/test/java/com/yahoo/container/jdisc/athenz/AthenzIdentityProviderTest.java new file mode 100644 index 00000000000..4b351f1d2c0 --- /dev/null +++ b/container-disc/src/test/java/com/yahoo/container/jdisc/athenz/AthenzIdentityProviderTest.java @@ -0,0 +1,47 @@ +package com.yahoo.container.jdisc.athenz; + +import com.yahoo.container.core.identity.IdentityConfig; +import org.junit.Assert; +import org.junit.Test; + +import java.io.IOException; + +import static org.mockito.Matchers.any; +import static org.mockito.Matchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * @author mortent + */ +public class AthenzIdentityProviderTest { + + @Test + public void ntoken_fetched_on_init() throws IOException { + IdentityConfig config = new IdentityConfig(new IdentityConfig.Builder().serviceName("tenantService").domain("tenantDomain")); + ServiceProviderApi serviceProviderApi = mock(ServiceProviderApi.class); + AthenzService athenzService = mock(AthenzService.class); + + when(serviceProviderApi.getSignedIdentityDocument()).thenReturn(getIdentityDocument()); + when(athenzService.sendInstanceRegisterRequest(any(), anyString())).thenReturn( + new InstanceIdentity(null,null,null,null,null,null, null, null, "TOKEN")); + + AthenzIdentityProvider identityProvider = new AthenzIdentityProvider(config, serviceProviderApi, athenzService); + + Assert.assertEquals("TOKEN", identityProvider.getNToken()); + } + + private String getIdentityDocument() { + return "{\n" + + " \"identity-document\": \"eyJwcm92aWRlci11bmlxdWUtaWQiOnsidGVuYW50IjoidGVuYW50IiwiYXBwbGljYXRpb24iOiJhcHBsaWNhdGlvbiIsImVudmlyb25tZW50IjoiZGV2IiwicmVnaW9uIjoidXMtbm9ydGgtMSIsImluc3RhbmNlIjoiZGVmYXVsdCIsImNsdXN0ZXItaWQiOiJkZWZhdWx0IiwiY2x1c3Rlci1pbmRleCI6MH0sImNvbmZpZ3NlcnZlci1ob3N0bmFtZSI6ImxvY2FsaG9zdCIsImluc3RhbmNlLWhvc3RuYW1lIjoieC55LmNvbSIsImNyZWF0ZWQtYXQiOjE1MDg3NDgyODUuNzQyMDAwMDAwfQ==\",\n" + + " \"signature\": \"kkEJB/98cy1FeXxzSjtvGH2a6BFgZu/9/kzCcAqRMZjENxnw5jyO1/bjZVzw2Sz4YHPsWSx2uxb32hiQ0U8rMP0zfA9nERIalSP0jB/hMU8laezGhdpk6VKZPJRC6YKAB9Bsv2qUIfMsSxkMqf66GUvjZAGaYsnNa2yHc1jIYHOGMeJO+HNPYJjGv26xPfAOPIKQzs3RmKrc3FoweTCsIwm5oblqekdJvVWYe0obwlOSB5uwc1zpq3Ie1QBFtJRuCGMVHg1pDPxXKBHLClGIrEvzLmICy6IRdHszSO5qiwujUD7sbrbM0sB/u0cYucxbcsGRUmBvme3UAw2mW9POVQ==\",\n" + + " \"signing-key-version\": 0,\n" + + " \"provider-unique-id\": \"tenant.application.dev.us-north-1.default.default.0\",\n" + + " \"dns-suffix\": \"dnsSuffix\",\n" + + " \"provider-service\": \"service\",\n" + + " \"zts-endpoint\": \"localhost/zts\", \n" + + " \"document-version\": 1\n" + + "}"; + + } +} @@ -499,6 +499,11 @@ <version>${jackson2.version}</version> </dependency> <dependency> + <groupId>com.fasterxml.jackson.datatype</groupId> + <artifactId>jackson-datatype-jsr310</artifactId> + <version>${jackson2.version}</version> + </dependency> + <dependency> <groupId>com.infradna.tool</groupId> <artifactId>bridge-method-annotation</artifactId> <version>1.4</version> @@ -953,6 +958,7 @@ <module>application-deploy-plugin</module> <module>application-model</module> <module>application-preprocessor</module> + <module>athenz-identity-provider-service</module> <module>bundle-plugin-test</module> <module>chain</module> <module>clustercontroller-apps</module> diff --git a/standalone-container/src/main/scala/com/yahoo/container/standalone/CloudConfigYinstVariables.scala b/standalone-container/src/main/scala/com/yahoo/container/standalone/CloudConfigYinstVariables.scala index 0a888dc1208..009de6fd541 100644 --- a/standalone-container/src/main/scala/com/yahoo/container/standalone/CloudConfigYinstVariables.scala +++ b/standalone-container/src/main/scala/com/yahoo/container/standalone/CloudConfigYinstVariables.scala @@ -38,6 +38,7 @@ class CloudConfigYinstVariables extends CloudConfigOptions { override val numParallelTenantLoaders = optionalYinstVar[java.lang.Integer]("num_parallel_tenant_loaders") override val dockerRegistry = optionalYinstVar[java.lang.String]("docker_registry") override val dockerVespaBaseImage = optionalYinstVar[java.lang.String]("docker_vespa_base_image") + override val serviceProviderEndpoint = optionalYinstVar[java.lang.String]("service_provider_endpoint") } object CloudConfigYinstVariables { |