summaryrefslogtreecommitdiffstats
path: root/athenz-identity-provider-service
diff options
context:
space:
mode:
Diffstat (limited to 'athenz-identity-provider-service')
-rw-r--r--athenz-identity-provider-service/OWNERS1
-rw-r--r--athenz-identity-provider-service/pom.xml144
-rw-r--r--athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/AthenzInstanceProviderService.java182
-rw-r--r--athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/impl/FileBackedKeyProvider.java40
-rw-r--r--athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/impl/IdentityDocumentGenerator.java89
-rw-r--r--athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/impl/InstanceValidator.java56
-rw-r--r--athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/impl/KeyProvider.java11
-rw-r--r--athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/impl/ProviderServiceServlet.java81
-rw-r--r--athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/impl/StatusServlet.java21
-rw-r--r--athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/impl/Utils.java23
-rw-r--r--athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/impl/model/IdentityDocument.java59
-rw-r--r--athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/impl/model/InstanceConfirmation.java99
-rw-r--r--athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/impl/model/ProviderUniqueId.java79
-rw-r--r--athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/impl/model/SignedIdentityDocument.java87
-rw-r--r--athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/package-info.java8
-rw-r--r--athenz-identity-provider-service/src/main/resources/configdefinitions/athenz-provider-service.def29
-rw-r--r--athenz-identity-provider-service/src/test/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/AthenzInstanceProviderServiceTest.java260
-rw-r--r--athenz-identity-provider-service/src/test/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/ScheduledExecutorServiceMock.java115
18 files changed, 1384 insertions, 0 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();
+ }
+}