diff options
author | Henning Baldersheim <balder@yahoo-inc.com> | 2023-03-04 14:27:33 +0100 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-03-04 14:27:33 +0100 |
commit | e1535b0552bd1993c31acde3606c1411cf769d5b (patch) | |
tree | d01c876617db76142fc60bc9d1fde3508dda2502 | |
parent | 51600f1613c1787c3083409204452175e028cb22 (diff) |
Revert "Mortent/reapply public athenz provider"
47 files changed, 2871 insertions, 93 deletions
diff --git a/CMakeLists.txt b/CMakeLists.txt index c995007663d..ce11196725f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -68,6 +68,7 @@ include_directories(BEFORE ${CMAKE_BINARY_DIR}/configdefinitions/src) add_subdirectory(airlift-zstd) add_subdirectory(ann_benchmark) add_subdirectory(application-model) +add_subdirectory(athenz-identity-provider-service) add_subdirectory(client) add_subdirectory(cloud-tenant-cd) add_subdirectory(clustercontroller-apps) diff --git a/athenz-identity-provider-service/CMakeLists.txt b/athenz-identity-provider-service/CMakeLists.txt new file mode 100644 index 00000000000..75208c49bd3 --- /dev/null +++ b/athenz-identity-provider-service/CMakeLists.txt @@ -0,0 +1,2 @@ +# Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +install_jar(athenz-identity-provider-service-jar-with-dependencies.jar) 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/README.md b/athenz-identity-provider-service/README.md new file mode 100644 index 00000000000..b25eb009e1b --- /dev/null +++ b/athenz-identity-provider-service/README.md @@ -0,0 +1,5 @@ +<!-- Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. --> +# Athenz Identity Provider Service + +An [Athenz Copper Argos](https://github.com/yahoo/athenz/blob/master/docs/copper_argos.md) provider implementation for configserver. + diff --git a/athenz-identity-provider-service/pom.xml b/athenz-identity-provider-service/pom.xml new file mode 100644 index 00000000000..f4daa43b8e3 --- /dev/null +++ b/athenz-identity-provider-service/pom.xml @@ -0,0 +1,186 @@ +<?xml version="1.0"?> +<!-- Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. --> +<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>8-SNAPSHOT</version> + <relativePath>../parent/pom.xml</relativePath> + </parent> + <dependencies> + <!-- PROVIDED --> + <dependency> + <groupId>com.google.inject</groupId> + <artifactId>guice</artifactId> + <scope>provided</scope> + </dependency> + <dependency> + <groupId>com.yahoo.vespa</groupId> + <artifactId>component</artifactId> + <version>${project.version}</version> + <scope>provided</scope> + </dependency> + <dependency> + <groupId>com.yahoo.vespa</groupId> + <artifactId>container-apache-http-client-bundle</artifactId> + <version>${project.version}</version> + <scope>provided</scope> + </dependency> + <dependency> + <groupId>com.yahoo.vespa</groupId> + <artifactId>container-dev</artifactId> + <version>${project.version}</version> + <scope>provided</scope> + </dependency> + <dependency> + <groupId>com.yahoo.vespa</groupId> + <artifactId>jdisc_core</artifactId> + <version>${project.version}</version> + <scope>provided</scope> + </dependency> + <dependency> + <groupId>com.fasterxml.jackson.core</groupId> + <artifactId>jackson-core</artifactId> + <scope>provided</scope> + </dependency> + <dependency> + <groupId>com.fasterxml.jackson.core</groupId> + <artifactId>jackson-databind</artifactId> + <scope>provided</scope> + </dependency> + <dependency> + <groupId>com.yahoo.vespa</groupId> + <artifactId>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> + <scope>provided</scope> + </dependency> + <dependency> + <groupId>com.yahoo.vespa</groupId> + <artifactId>config-model-api</artifactId> + <version>${project.version}</version> + <scope>provided</scope> + </dependency> + <dependency> + <groupId>com.yahoo.vespa</groupId> + <artifactId>vespa-athenz</artifactId> + <version>${project.version}</version> + <scope>provided</scope> + </dependency> + <dependency> + <groupId>com.yahoo.vespa</groupId> + <artifactId>security-utils</artifactId> + <version>${project.version}</version> + <scope>provided</scope> + </dependency> + + <!-- TEST --> + <dependency> + <groupId>com.yahoo.vespa</groupId> + <artifactId>zkfacade</artifactId> + <version>${project.version}</version> + <scope>test</scope> + </dependency> + <dependency> + <groupId>org.junit.jupiter</groupId> + <artifactId>junit-jupiter-api</artifactId> + <scope>test</scope> + </dependency> + <dependency> + <groupId>org.junit.jupiter</groupId> + <artifactId>junit-jupiter-engine</artifactId> + <scope>test</scope> + </dependency> + <dependency> + <groupId>com.yahoo.vespa</groupId> + <artifactId>orchestrator</artifactId> + <version>${project.version}</version> + <scope>test</scope> + </dependency> + <dependency> + <groupId>org.mockito</groupId> + <artifactId>mockito-core</artifactId> + <scope>test</scope> + </dependency> + <dependency> + <groupId>com.yahoo.vespa</groupId> + <artifactId>application</artifactId> + <version>${project.version}</version> + <scope>test</scope> + <exclusions> + <exclusion> + <groupId>junit</groupId> + <artifactId>junit</artifactId> + </exclusion> + </exclusions> + </dependency> + <dependency> + <groupId>com.yahoo.vespa</groupId> + <artifactId>testutil</artifactId> + <version>${project.version}</version> + <scope>test</scope> + <exclusions> + <exclusion> + <groupId>junit</groupId> + <artifactId>junit</artifactId> + </exclusion> + <exclusion> + <groupId>org.hamcrest</groupId> + <artifactId>hamcrest-core</artifactId> + </exclusion> + <exclusion> + <groupId>org.hamcrest</groupId> + <artifactId>hamcrest-library</artifactId> + </exclusion> + </exclusions> + </dependency> + <dependency> + <groupId>com.yahoo.vespa</groupId> + <artifactId>flags</artifactId> + <version>${project.version}</version> + <scope>test</scope> + </dependency> + <dependency> + <groupId>com.yahoo.vespa</groupId> + <artifactId>container-test</artifactId> + <version>${project.version}</version> + <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> + <plugin> + <groupId>org.apache.maven.plugins</groupId> + <artifactId>maven-surefire-plugin</artifactId> + <configuration> + <!-- Illegal reflective access by guice. TODO: try to remove for guice >3.0 --> + <argLine> + --add-opens=java.base/java.lang=ALL-UNNAMED + </argLine> + </configuration> + </plugin> + </plugins> + </build> + +</project> diff --git a/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/CertificateExpiryMetricUpdater.java b/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/CertificateExpiryMetricUpdater.java new file mode 100644 index 00000000000..f3568caac04 --- /dev/null +++ b/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/CertificateExpiryMetricUpdater.java @@ -0,0 +1,59 @@ +// Copyright Yahoo. 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.yahoo.component.annotation.Inject; +import com.yahoo.component.AbstractComponent; +import com.yahoo.jdisc.Metric; + +import java.time.Duration; +import java.time.Instant; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * @author freva + */ +public class CertificateExpiryMetricUpdater extends AbstractComponent { + + private static final Duration METRIC_REFRESH_PERIOD = Duration.ofMinutes(5); + private static final String ATHENZ_CONFIGSERVER_CERT_METRIC_NAME = "athenz-configserver-cert.expiry.seconds"; + + private final Logger logger = Logger.getLogger(CertificateExpiryMetricUpdater.class.getName()); + private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(); + private final Metric metric; + private final ConfigserverSslContextFactoryProvider provider; + + @Inject + public CertificateExpiryMetricUpdater(Metric metric, + ConfigserverSslContextFactoryProvider provider) { + this.metric = metric; + this.provider = provider; + + scheduler.scheduleAtFixedRate(this::updateMetrics, + 30/*initial delay*/, + METRIC_REFRESH_PERIOD.getSeconds(), + TimeUnit.SECONDS); + } + + @Override + public void deconstruct() { + try { + scheduler.shutdownNow(); + scheduler.awaitTermination(30, TimeUnit.SECONDS); + } catch (InterruptedException e) { + throw new RuntimeException("Failed to shutdown certificate expiry metrics updater on time", e); + } + } + + private void updateMetrics() { + try { + Duration keyStoreExpiry = Duration.between(Instant.now(), provider.getCertificateNotAfter()); + metric.set(ATHENZ_CONFIGSERVER_CERT_METRIC_NAME, keyStoreExpiry.getSeconds(), null); + } catch (Exception e) { + logger.log(Level.WARNING, "Failed to update key store expiry metric: " + e.getMessage(), e); + } + } +} diff --git a/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/CkmsKeyProvider.java b/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/CkmsKeyProvider.java new file mode 100644 index 00000000000..c659c454420 --- /dev/null +++ b/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/CkmsKeyProvider.java @@ -0,0 +1,64 @@ +// Copyright Yahoo. 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.yahoo.component.annotation.Inject; +import com.yahoo.config.provision.Zone; +import com.yahoo.container.jdisc.secretstore.SecretStore; +import com.yahoo.security.KeyUtils; +import com.yahoo.vespa.hosted.athenz.instanceproviderservice.KeyProvider; +import com.yahoo.vespa.hosted.athenz.instanceproviderservice.config.AthenzProviderServiceConfig; + +import java.security.KeyPair; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.util.HashMap; +import java.util.Map; + +/** + * @author mortent + * @author bjorncs + */ +@SuppressWarnings("unused") // Injected component +public class CkmsKeyProvider implements KeyProvider { + + private final SecretStore secretStore; + private final String secretName; + private final Map<Integer, KeyPair> secrets; + + @Inject + public CkmsKeyProvider(SecretStore secretStore, + Zone zone, + AthenzProviderServiceConfig config) { + this.secretStore = secretStore; + this.secretName = config.secretName(); + this.secrets = new HashMap<>(); + } + + @Override + public PrivateKey getPrivateKey(int version) { + return getKeyPair(version).getPrivate(); + } + + @Override + public PublicKey getPublicKey(int version) { + return getKeyPair(version).getPublic(); + } + + @Override + public KeyPair getKeyPair(int version) { + synchronized (secrets) { + KeyPair keyPair = secrets.get(version); + if (keyPair == null) { + keyPair = readKeyPair(version); + secrets.put(version, keyPair); + } + return keyPair; + } + } + + private KeyPair readKeyPair(int version) { + PrivateKey privateKey = KeyUtils.fromPemEncodedPrivateKey(secretStore.getSecret(secretName, version)); + PublicKey publicKey = KeyUtils.extractPublicKey(privateKey); + return new KeyPair(publicKey, privateKey); + } +} diff --git a/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/ConfigserverSslContextFactoryProvider.java b/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/ConfigserverSslContextFactoryProvider.java new file mode 100644 index 00000000000..61a4a0fe41f --- /dev/null +++ b/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/ConfigserverSslContextFactoryProvider.java @@ -0,0 +1,182 @@ +// Copyright Yahoo. 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.yahoo.component.annotation.Inject; +import com.yahoo.jdisc.http.ssl.impl.TlsContextBasedProvider; +import com.yahoo.security.KeyStoreBuilder; +import com.yahoo.security.KeyStoreType; +import com.yahoo.security.KeyUtils; +import com.yahoo.security.SslContextBuilder; +import com.yahoo.security.tls.DefaultTlsContext; +import com.yahoo.security.MutableX509KeyManager; +import com.yahoo.security.tls.PeerAuthentication; +import com.yahoo.security.tls.TlsContext; +import com.yahoo.vespa.athenz.api.AthenzService; +import com.yahoo.vespa.athenz.client.zts.DefaultZtsClient; +import com.yahoo.vespa.athenz.client.zts.Identity; +import com.yahoo.vespa.athenz.client.zts.ZtsClient; +import com.yahoo.vespa.athenz.identity.ServiceIdentityProvider; +import com.yahoo.vespa.athenz.utils.SiaUtils; +import com.yahoo.vespa.defaults.Defaults; +import com.yahoo.vespa.hosted.athenz.instanceproviderservice.config.AthenzProviderServiceConfig; + +import javax.net.ssl.SSLContext; +import java.net.URI; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.security.KeyPair; +import java.security.KeyStore; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.security.cert.X509Certificate; +import java.time.Duration; +import java.time.Instant; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Configures the JDisc https connector with the configserver's Athenz provider certificate and private key. + * + * @author bjorncs + */ +public class ConfigserverSslContextFactoryProvider extends TlsContextBasedProvider { + + private static final String CERTIFICATE_ALIAS = "athenz"; + private static final Duration EXPIRATION_MARGIN = Duration.ofHours(6); + private static final Path VESPA_SIA_DIRECTORY = Paths.get(Defaults.getDefaults().underVespaHome("var/vespa/sia")); + + private static final Logger log = Logger.getLogger(ConfigserverSslContextFactoryProvider.class.getName()); + + private final TlsContext tlsContext; + private final MutableX509KeyManager keyManager = new MutableX509KeyManager(); + private final ScheduledExecutorService scheduler = + Executors.newSingleThreadScheduledExecutor(runnable -> new Thread(runnable, "configserver-ssl-context-factory-provider")); + private final ZtsClient ztsClient; + private final KeyProvider keyProvider; + private final AthenzProviderServiceConfig athenzProviderServiceConfig; + private final AthenzService configserverIdentity; + + @Inject + public ConfigserverSslContextFactoryProvider(ServiceIdentityProvider bootstrapIdentity, + KeyProvider keyProvider, + AthenzProviderServiceConfig config) { + this.athenzProviderServiceConfig = config; + this.ztsClient = new DefaultZtsClient.Builder(URI.create(athenzProviderServiceConfig.ztsUrl())) + .withIdentityProvider(bootstrapIdentity).build(); + this.keyProvider = keyProvider; + this.configserverIdentity = new AthenzService(athenzProviderServiceConfig.domain(), athenzProviderServiceConfig.serviceName()); + + Duration updatePeriod = Duration.ofDays(config.updatePeriodDays()); + Path trustStoreFile = Paths.get(config.athenzCaTrustStore()); + this.tlsContext = createTlsContext(keyProvider, keyManager, trustStoreFile, updatePeriod, configserverIdentity, ztsClient, athenzProviderServiceConfig); + scheduler.scheduleAtFixedRate(new KeystoreUpdater(keyManager), + updatePeriod.toDays()/*initial delay*/, + updatePeriod.toDays(), + TimeUnit.DAYS); + } + + @Override + protected TlsContext getTlsContext(String containerId, int port) { + return tlsContext; + } + + Instant getCertificateNotAfter() { + return keyManager.currentManager().getCertificateChain(CERTIFICATE_ALIAS)[0].getNotAfter().toInstant(); + } + + @Override + public void deconstruct() { + try { + scheduler.shutdownNow(); + scheduler.awaitTermination(30, TimeUnit.SECONDS); + ztsClient.close(); + super.deconstruct(); + } catch (InterruptedException e) { + throw new RuntimeException("Failed to shutdown Athenz certificate updater on time", e); + } + } + + private static TlsContext createTlsContext(KeyProvider keyProvider, + MutableX509KeyManager keyManager, + Path trustStoreFile, + Duration updatePeriod, + AthenzService configserverIdentity, + ZtsClient ztsClient, + AthenzProviderServiceConfig zoneConfig) { + KeyStore keyStore = + tryReadKeystoreFile(configserverIdentity, updatePeriod) + .orElseGet(() -> updateKeystore(configserverIdentity, generateKeystorePassword(), keyProvider, ztsClient, zoneConfig)); + keyManager.updateKeystore(keyStore, new char[0]); + SSLContext sslContext = new SslContextBuilder() + .withTrustStore(trustStoreFile, KeyStoreType.JKS) + .withKeyManager(keyManager) + .build(); + return new DefaultTlsContext(sslContext, PeerAuthentication.WANT); + } + + private static Optional<KeyStore> tryReadKeystoreFile(AthenzService configserverIdentity, Duration updatePeriod) { + Optional<X509Certificate> certificate = SiaUtils.readCertificateFile(VESPA_SIA_DIRECTORY, configserverIdentity); + if (!certificate.isPresent()) return Optional.empty(); + Optional<PrivateKey> privateKey = SiaUtils.readPrivateKeyFile(VESPA_SIA_DIRECTORY, configserverIdentity); + if (!privateKey.isPresent()) return Optional.empty(); + Instant minimumExpiration = Instant.now().plus(updatePeriod).plus(EXPIRATION_MARGIN); + boolean isExpired = certificate.get().getNotAfter().toInstant().isBefore(minimumExpiration); + if (isExpired) return Optional.empty(); + KeyStore keyStore = KeyStoreBuilder.withType(KeyStoreType.JKS) + .withKeyEntry(CERTIFICATE_ALIAS, privateKey.get(), certificate.get()) + .build(); + return Optional.of(keyStore); + } + + private static KeyStore updateKeystore(AthenzService configserverIdentity, + char[] keystorePwd, + KeyProvider keyProvider, + ZtsClient ztsClient, + AthenzProviderServiceConfig zoneConfig) { + PrivateKey privateKey = keyProvider.getPrivateKey(zoneConfig.secretVersion()); + PublicKey publicKey = KeyUtils.extractPublicKey(privateKey); + Identity serviceIdentity = ztsClient.getServiceIdentity(configserverIdentity, + Integer.toString(zoneConfig.secretVersion()), + new KeyPair(publicKey, privateKey), + zoneConfig.certDnsSuffix()); + X509Certificate certificate = serviceIdentity.certificate(); + SiaUtils.writeCertificateFile(VESPA_SIA_DIRECTORY, configserverIdentity, certificate); + SiaUtils.writePrivateKeyFile(VESPA_SIA_DIRECTORY, configserverIdentity, privateKey); + Instant expirationTime = certificate.getNotAfter().toInstant(); + Duration expiry = Duration.between(certificate.getNotBefore().toInstant(), expirationTime); + log.log(Level.INFO, String.format("Got Athenz x509 certificate with expiry %s (expires %s)", expiry, expirationTime)); + return KeyStoreBuilder.withType(KeyStoreType.JKS) + .withKeyEntry(CERTIFICATE_ALIAS, privateKey, keystorePwd, certificate) + .build(); + } + + private static char[] generateKeystorePassword() { + return UUID.randomUUID().toString().toCharArray(); + } + + private class KeystoreUpdater implements Runnable { + final MutableX509KeyManager keyManager; + + KeystoreUpdater(MutableX509KeyManager keyManager) { + this.keyManager = keyManager; + } + + @Override + public void run() { + try { + log.log(Level.INFO, "Updating configserver provider certificate from ZTS"); + char[] keystorePwd = generateKeystorePassword(); + KeyStore keyStore = updateKeystore(configserverIdentity, keystorePwd, keyProvider, ztsClient, athenzProviderServiceConfig); + keyManager.updateKeystore(keyStore, keystorePwd); + log.log(Level.INFO, "Certificate successfully updated"); + } catch (Throwable t) { + log.log(Level.SEVERE, "Failed to update certificate from ZTS: " + t.getMessage(), t); + } + } + } +} diff --git a/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/IdentityDocumentGenerator.java b/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/IdentityDocumentGenerator.java new file mode 100644 index 00000000000..5143a38b2c1 --- /dev/null +++ b/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/IdentityDocumentGenerator.java @@ -0,0 +1,83 @@ +// Copyright Yahoo. 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.yahoo.component.annotation.Inject; +import com.yahoo.config.provision.Zone; +import com.yahoo.net.HostName; +import com.yahoo.vespa.athenz.api.AthenzService; +import com.yahoo.vespa.athenz.identityprovider.api.ClusterType; +import com.yahoo.vespa.athenz.identityprovider.api.IdentityType; +import com.yahoo.vespa.athenz.identityprovider.api.SignedIdentityDocument; +import com.yahoo.vespa.athenz.identityprovider.api.VespaUniqueInstanceId; +import com.yahoo.vespa.athenz.identityprovider.client.IdentityDocumentSigner; +import com.yahoo.vespa.hosted.athenz.instanceproviderservice.config.AthenzProviderServiceConfig; +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.time.Instant; +import java.util.HashSet; +import java.util.Set; + +/** + * Generates a signed identity document for a given hostname and type + * + * @author mortent + * @author bjorncs + */ +public class IdentityDocumentGenerator { + + private final IdentityDocumentSigner signer = new IdentityDocumentSigner(); + private final NodeRepository nodeRepository; + private final Zone zone; + private final KeyProvider keyProvider; + private final AthenzProviderServiceConfig athenzProviderServiceConfig; + + @Inject + public IdentityDocumentGenerator(AthenzProviderServiceConfig config, + NodeRepository nodeRepository, + Zone zone, + KeyProvider keyProvider) { + this.athenzProviderServiceConfig = config; + this.nodeRepository = nodeRepository; + this.zone = zone; + this.keyProvider = keyProvider; + } + + public SignedIdentityDocument generateSignedIdentityDocument(String hostname, IdentityType identityType) { + try { + Node node = nodeRepository.nodes().node(hostname).orElseThrow(() -> new RuntimeException("Unable to find node " + hostname)); + Allocation allocation = node.allocation().orElseThrow(() -> new RuntimeException("No allocation for node " + node.hostname())); + VespaUniqueInstanceId providerUniqueId = new VespaUniqueInstanceId( + allocation.membership().index(), + allocation.membership().cluster().id().value(), + allocation.owner().instance().value(), + allocation.owner().application().value(), + allocation.owner().tenant().value(), + zone.region().value(), + zone.environment().value(), + identityType); + + Set<String> ips = new HashSet<>(node.ipConfig().primary()); + + PrivateKey privateKey = keyProvider.getPrivateKey(athenzProviderServiceConfig.secretVersion()); + AthenzService providerService = new AthenzService(athenzProviderServiceConfig.domain(), athenzProviderServiceConfig.serviceName()); + + String configServerHostname = HostName.getLocalhost(); + Instant createdAt = Instant.now(); + var clusterType = ClusterType.from(allocation.membership().cluster().type().name()); + String signature = signer.generateSignature( + providerUniqueId, providerService, configServerHostname, + node.hostname(), createdAt, ips, identityType, privateKey); + return new SignedIdentityDocument( + signature, athenzProviderServiceConfig.secretVersion(), providerUniqueId, providerService, + SignedIdentityDocument.DEFAULT_DOCUMENT_VERSION, configServerHostname, node.hostname(), + createdAt, ips, identityType, clusterType); + } catch (Exception e) { + throw new RuntimeException("Exception generating identity document: " + e.getMessage(), e); + } + } + +} + diff --git a/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/IdentityProviderRequestHandler.java b/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/IdentityProviderRequestHandler.java new file mode 100644 index 00000000000..c1dd70d7656 --- /dev/null +++ b/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/IdentityProviderRequestHandler.java @@ -0,0 +1,99 @@ +// Copyright Yahoo. 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.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jdk8.Jdk8Module; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.yahoo.component.annotation.Inject; +import com.yahoo.container.jdisc.ThreadedHttpRequestHandler; +import com.yahoo.restapi.RestApi; +import com.yahoo.restapi.RestApiException; +import com.yahoo.restapi.RestApiRequestHandler; +import com.yahoo.vespa.athenz.identityprovider.api.EntityBindingsMapper; +import com.yahoo.vespa.athenz.identityprovider.api.IdentityType; +import com.yahoo.vespa.athenz.identityprovider.api.bindings.SignedIdentityDocumentEntity; + +import java.util.logging.Level; + +/** + * Handler implementing the Athenz Identity Provider API (Copper Argos). + * + * @author bjorncs + */ +public class IdentityProviderRequestHandler extends RestApiRequestHandler<IdentityProviderRequestHandler> { + + private final IdentityDocumentGenerator documentGenerator; + private final InstanceValidator instanceValidator; + + @Inject + public IdentityProviderRequestHandler(ThreadedHttpRequestHandler.Context context, + IdentityDocumentGenerator documentGenerator, + InstanceValidator instanceValidator) { + super(context, IdentityProviderRequestHandler::createRestApi); + this.documentGenerator = documentGenerator; + this.instanceValidator = instanceValidator; + } + + private static RestApi createRestApi(IdentityProviderRequestHandler self) { + return RestApi.builder() + .addRoute(RestApi.route("/athenz/v1/provider/identity-document/node/{host}") + .get(self::getNodeIdentityDocument)) + .addRoute(RestApi.route("/athenz/v1/provider/identity-document/tenant/{host}") + .get(self::getTenantIdentityDocument)) + .addRoute(RestApi.route("/athenz/v1/provider/instance") + .post(InstanceConfirmation.class, self::confirmInstance)) + .addRoute(RestApi.route("/athenz/v1/provider/refresh") + .post(InstanceConfirmation.class, self::confirmInstanceRefresh)) + .registerJacksonRequestEntity(InstanceConfirmation.class) + .registerJacksonResponseEntity(InstanceConfirmation.class) + .registerJacksonResponseEntity(SignedIdentityDocumentEntity.class) + // Overriding object mapper to change serialization of timestamps + .setObjectMapper(new ObjectMapper() + .registerModule(new JavaTimeModule()) + .registerModule(new Jdk8Module()) + .configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, true)) + .build(); + } + + private SignedIdentityDocumentEntity getNodeIdentityDocument(RestApi.RequestContext context) { + String host = context.pathParameters().getString("host").orElse(null); + return getIdentityDocument(host, IdentityType.NODE); + } + + private SignedIdentityDocumentEntity getTenantIdentityDocument(RestApi.RequestContext context) { + String host = context.pathParameters().getString("host").orElse(null); + return getIdentityDocument(host, IdentityType.TENANT); + } + + private InstanceConfirmation confirmInstance(RestApi.RequestContext context, InstanceConfirmation instanceConfirmation) { + log.log(Level.FINE, () -> instanceConfirmation.toString()); + if (!instanceValidator.isValidInstance(instanceConfirmation)) { + log.log(Level.SEVERE, "Invalid instance: " + instanceConfirmation); + throw new RestApiException.Forbidden("Instance is invalid"); + } + return instanceConfirmation; + } + + private InstanceConfirmation confirmInstanceRefresh(RestApi.RequestContext context, InstanceConfirmation instanceConfirmation) { + log.log(Level.FINE, () -> instanceConfirmation.toString()); + if (!instanceValidator.isValidRefresh(instanceConfirmation)) { + log.log(Level.SEVERE, "Invalid instance refresh: " + instanceConfirmation); + throw new RestApiException.Forbidden("Instance is invalid"); + } + return instanceConfirmation; + } + + private SignedIdentityDocumentEntity getIdentityDocument(String hostname, IdentityType identityType) { + if (hostname == null) { + throw new RestApiException.BadRequest("The 'hostname' query parameter is missing"); + } + try { + return EntityBindingsMapper.toSignedIdentityDocumentEntity(documentGenerator.generateSignedIdentityDocument(hostname, identityType)); + } catch (Exception e) { + String message = String.format("Unable to generate identity document for '%s': %s", hostname, e.getMessage()); + log.log(Level.SEVERE, message, e); + throw new RestApiException.InternalServerError(message, e); + } + } +} diff --git a/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/InstanceConfirmation.java b/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/InstanceConfirmation.java new file mode 100644 index 00000000000..6c09a35ee3d --- /dev/null +++ b/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/InstanceConfirmation.java @@ -0,0 +1,99 @@ +// Copyright Yahoo. 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.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.athenz.identityprovider.api.bindings.SignedIdentityDocumentEntity; + +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 SignedIdentityDocumentEntity signedIdentityDocument; + @JsonUnwrapped public final Map<String, String> 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) + SignedIdentityDocumentEntity signedIdentityDocument) { + this.provider = provider; + this.domain = domain; + this.service = service; + this.signedIdentityDocument = signedIdentityDocument; + } + + @JsonAnySetter + public void set(String name, String 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<SignedIdentityDocumentEntity> { + @Override + public SignedIdentityDocumentEntity deserialize( + JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException { + String value = jsonParser.getValueAsString(); + return Utils.getMapper().readValue(value, SignedIdentityDocumentEntity.class); + } + } + + public static class SignedIdentitySerializer extends JsonSerializer<SignedIdentityDocumentEntity> { + @Override + public void serialize( + SignedIdentityDocumentEntity 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/InstanceValidator.java b/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/InstanceValidator.java new file mode 100644 index 00000000000..d8bbf743d8c --- /dev/null +++ b/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/InstanceValidator.java @@ -0,0 +1,265 @@ +// Copyright Yahoo. 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.common.net.InetAddresses; +import com.yahoo.component.annotation.Inject; +import com.yahoo.config.model.api.ApplicationInfo; +import com.yahoo.config.model.api.ServiceInfo; +import com.yahoo.config.model.api.SuperModelProvider; +import com.yahoo.config.provision.ApplicationId; +import com.yahoo.vespa.athenz.api.AthenzService; +import com.yahoo.vespa.athenz.identityprovider.api.ClusterType; +import com.yahoo.vespa.athenz.identityprovider.api.EntityBindingsMapper; +import com.yahoo.vespa.athenz.identityprovider.api.SignedIdentityDocument; +import com.yahoo.vespa.athenz.identityprovider.api.VespaUniqueInstanceId; +import com.yahoo.vespa.athenz.identityprovider.client.IdentityDocumentSigner; +import com.yahoo.vespa.hosted.athenz.instanceproviderservice.config.AthenzProviderServiceConfig; +import com.yahoo.vespa.hosted.provision.Node; +import com.yahoo.vespa.hosted.provision.NodeRepository; + +import java.net.InetAddress; +import java.net.URI; +import java.security.PublicKey; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.function.Supplier; +import java.util.logging.Level; +import java.util.logging.Logger; +import java.util.stream.Collectors; + +/** + * Verifies that the instance's identity document is valid + * + * @author bjorncs + * @author mortent + */ +public class InstanceValidator { + + private static final Logger log = Logger.getLogger(InstanceValidator.class.getName()); + static final String SERVICE_PROPERTIES_DOMAIN_KEY = "identity.domain"; + static final String SERVICE_PROPERTIES_SERVICE_KEY = "identity.service"; + static final String INSTANCE_ID_DELIMITER = ".instanceid.athenz."; + + public static final String SAN_IPS_ATTRNAME = "sanIP"; + public static final String SAN_DNS_ATTRNAME = "sanDNS"; + public static final String SAN_URI_ATTRNAME = "sanURI"; + + private final AthenzService tenantDockerContainerIdentity; + private final IdentityDocumentSigner signer; + private final KeyProvider keyProvider; + private final SuperModelProvider superModelProvider; + private final NodeRepository nodeRepository; + + @Inject + public InstanceValidator(KeyProvider keyProvider, + SuperModelProvider superModelProvider, + NodeRepository nodeRepository, + AthenzProviderServiceConfig config) { + this(keyProvider, superModelProvider, nodeRepository, new IdentityDocumentSigner(), new AthenzService(config.tenantService())); + } + + public InstanceValidator(KeyProvider keyProvider, + SuperModelProvider superModelProvider, + NodeRepository nodeRepository, + IdentityDocumentSigner identityDocumentSigner, + AthenzService tenantIdentity){ + this.keyProvider = keyProvider; + this.superModelProvider = superModelProvider; + this.nodeRepository = nodeRepository; + this.signer = identityDocumentSigner; + this.tenantDockerContainerIdentity = tenantIdentity; + } + + public boolean isValidInstance(InstanceConfirmation instanceConfirmation) { + try { + validateInstance(instanceConfirmation); + return true; + } catch (ValidationException e) { + log.log(e.logLevel(), e.messageSupplier()); + return false; + } + } + + public void validateInstance(InstanceConfirmation req) throws ValidationException { + SignedIdentityDocument signedIdentityDocument = EntityBindingsMapper.toSignedIdentityDocument(req.signedIdentityDocument); + VespaUniqueInstanceId providerUniqueId = signedIdentityDocument.providerUniqueId(); + ApplicationId applicationId = ApplicationId.from( + providerUniqueId.tenant(), providerUniqueId.application(), providerUniqueId.instance()); + + VespaUniqueInstanceId csrProviderUniqueId = getVespaUniqueInstanceId(req); + if(! providerUniqueId.equals(csrProviderUniqueId)) { + var msg = String.format("Instance %s has invalid provider unique ID in CSR (%s)", providerUniqueId, csrProviderUniqueId); + throw new ValidationException(Level.WARNING, () -> msg); + } + + if (! isSameIdentityAsInServicesXml(applicationId, req.domain, req.service)) { + Supplier<String> msg = () -> "Invalid identity '%s.%s' in services.xml".formatted(req.domain, req.service); + throw new ValidationException(Level.FINE, msg); + } + + log.log(Level.FINE, () -> String.format("Validating instance %s.", providerUniqueId)); + + PublicKey publicKey = keyProvider.getPublicKey(signedIdentityDocument.signingKeyVersion()); + if (! signer.hasValidSignature(signedIdentityDocument, publicKey)) { + var msg = String.format("Instance %s has invalid signature.", providerUniqueId); + throw new ValidationException(Level.SEVERE, () -> msg); + } + + validateAttributes(req, providerUniqueId); + log.log(Level.FINE, () -> String.format("Instance %s is valid.", providerUniqueId)); + } + + // TODO Add actual validation. Cannot reuse isValidInstance as identity document is not part of the refresh request. + // We'll have to perform some validation on the instance id and other fields of the attribute map. + // Separate between tenant and node certificate as well. + public boolean isValidRefresh(InstanceConfirmation confirmation) { + log.log(Level.FINE, () -> String.format("Accepting refresh for instance with identity '%s', provider '%s', instanceId '%s'.", + new AthenzService(confirmation.domain, confirmation.service).getFullName(), + confirmation.provider, + confirmation.attributes.get(SAN_DNS_ATTRNAME))); + try { + validateAttributes(confirmation, getVespaUniqueInstanceId(confirmation)); + return true; + } catch (ValidationException e) { + log.log(e.logLevel(), e.messageSupplier()); + return false; + } catch (Exception e) { + log.log(Level.WARNING, "Encountered exception while refreshing certificate for confirmation: " + confirmation, e); + return false; + } + } + + private VespaUniqueInstanceId getVespaUniqueInstanceId(InstanceConfirmation instanceConfirmation) { + // Find a list of SAN DNS + List<String> sanDNS = Optional.ofNullable(instanceConfirmation.attributes.get(SAN_DNS_ATTRNAME)) + .map(s -> s.split(",")) + .map(Arrays::asList).stream().flatMap(Collection::stream).toList(); + + return sanDNS.stream() + .filter(dns -> dns.contains(INSTANCE_ID_DELIMITER)) + .findFirst() + .map(s -> s.replaceAll(INSTANCE_ID_DELIMITER + ".*", "")) + .map(VespaUniqueInstanceId::fromDottedString) + .orElse(null); + } + + private void validateAttributes(InstanceConfirmation confirmation, VespaUniqueInstanceId vespaUniqueInstanceId) + throws ValidationException { + if(vespaUniqueInstanceId == null) { + var msg = "Unable to find unique instance ID in refresh request: " + confirmation.toString(); + throw new ValidationException(Level.WARNING, () -> msg); + } + + // Find node matching vespa unique id + Node node = nodeRepository.nodes().list().stream() + .filter(n -> n.allocation().isPresent()) + .filter(n -> nodeMatchesVespaUniqueId(n, vespaUniqueInstanceId)) + .findFirst() // Should be only one + .orElse(null); + if(node == null) { + var msg = "Invalid InstanceConfirmation, No nodes matching uniqueId: " + vespaUniqueInstanceId; + throw new ValidationException(Level.WARNING, () -> msg); + } + + // Find list of ipaddresses + List<InetAddress> ips = Optional.ofNullable(confirmation.attributes.get(SAN_IPS_ATTRNAME)) + .map(s -> s.split(",")) + .map(Arrays::asList).stream().flatMap(Collection::stream) + .map(InetAddresses::forString) + .toList(); + + List<InetAddress> nodeIpAddresses = node.ipConfig().primary().stream() + .map(InetAddresses::forString) + .toList(); + + // Validate that ipaddresses in request are valid for node + + if(! nodeIpAddresses.containsAll(ips)) { + var msg = "Invalid InstanceConfirmation, wrong ip in : " + vespaUniqueInstanceId; + throw new ValidationException(Level.WARNING, () -> msg); + } + + var urisCommaSeparated = confirmation.attributes.get(SAN_URI_ATTRNAME); + Set<URI> requestedUris; + try { + requestedUris = Optional.ofNullable(urisCommaSeparated).stream() + .flatMap(s -> Arrays.stream(s.split(","))).map(URI::create).collect(Collectors.toSet()); + } catch (IllegalArgumentException e) { + throw new ValidationException(Level.WARNING, () -> "Invalid SAN URIs: " + urisCommaSeparated, e); + } + var clusterType = node.allocation().map(a -> a.membership().cluster().type()).orElse(null); + Set<URI> allowedUris = clusterType != null + ? Set.of(ClusterType.from(clusterType.name()).asCertificateSanUri()) : Set.of(); + if (!allowedUris.containsAll(requestedUris)) { + Supplier<String> msg = () -> "Illegal SAN URIs: expected '%s' found '%s'".formatted(allowedUris, requestedUris); + throw new ValidationException(Level.WARNING, msg); + } + } + + private boolean nodeMatchesVespaUniqueId(Node node, VespaUniqueInstanceId vespaUniqueInstanceId) { + return node.allocation().map(allocation -> + allocation.membership().index() == vespaUniqueInstanceId.clusterIndex() && + allocation.membership().cluster().id().value().equals(vespaUniqueInstanceId.clusterId()) && + allocation.owner().instance().value().equals(vespaUniqueInstanceId.instance()) && + allocation.owner().application().value().equals(vespaUniqueInstanceId.application()) && + allocation.owner().tenant().value().equals(vespaUniqueInstanceId.tenant())) + .orElse(false); + } + + // If/when we don't care about logging exactly whats wrong, this can be simplified + // TODO Use identity type to determine if this check should be performed + private boolean isSameIdentityAsInServicesXml(ApplicationId applicationId, String domain, String service) { + + Optional<ApplicationInfo> applicationInfo = superModelProvider.getSuperModel().getApplicationInfo(applicationId); + + if (applicationInfo.isEmpty()) { + log.info(String.format("Could not find application info for %s, existing applications: %s", + applicationId.serializedForm(), + superModelProvider.getSuperModel().getAllApplicationInfos())); + return false; + } + + if (tenantDockerContainerIdentity.equals(new AthenzService(domain, service))) { + return true; + } + + Optional<ServiceInfo> matchingServiceInfo = applicationInfo.get() + .getModel() + .getHosts() + .stream() + .flatMap(hostInfo -> hostInfo.getServices().stream()) + .filter(serviceInfo -> serviceInfo.getProperty(SERVICE_PROPERTIES_DOMAIN_KEY).isPresent()) + .filter(serviceInfo -> serviceInfo.getProperty(SERVICE_PROPERTIES_SERVICE_KEY).isPresent()) + .findFirst(); + + if (matchingServiceInfo.isEmpty()) { + log.info(String.format("Application %s has not specified domain/service", applicationId.serializedForm())); + return false; + } + + String domainInConfig = matchingServiceInfo.get().getProperty(SERVICE_PROPERTIES_DOMAIN_KEY).get(); + String serviceInConfig = matchingServiceInfo.get().getProperty(SERVICE_PROPERTIES_SERVICE_KEY).get(); + if (!domainInConfig.equals(domain) || !serviceInConfig.equals(service)) { + log.warning(String.format("domain '%s' or service '%s' does not match the one in config for application %s", + domain, service, applicationId.serializedForm())); + return false; + } + + return true; + } + + public static class ValidationException extends Exception { + private final Level logLevel; + private final Supplier<String> msg; + + public ValidationException(Level logLevel, Supplier<String> msg) { this(logLevel, msg, null); } + public ValidationException(Level logLevel, Supplier<String> msg, Throwable cause) { super(cause); this.logLevel = logLevel; this.msg = msg; } + + @Override public String getMessage() { return msg.get(); } + public Level logLevel() { return logLevel; } + public Supplier<String> messageSupplier() { return msg; } + } +} diff --git a/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/KeyProvider.java b/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/KeyProvider.java new file mode 100644 index 00000000000..324f927fd73 --- /dev/null +++ b/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/KeyProvider.java @@ -0,0 +1,19 @@ +// Copyright Yahoo. 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.security.KeyPair; +import java.security.PrivateKey; +import java.security.PublicKey; + +/** + * @author bjorncs + */ +public interface KeyProvider { + PrivateKey getPrivateKey(int version); + + PublicKey getPublicKey(int version); + + default KeyPair getKeyPair(int version) { + return new KeyPair(getPublicKey(version), getPrivateKey(version)); + } +} diff --git a/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/Utils.java b/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/Utils.java new file mode 100644 index 00000000000..5c4942f37cb --- /dev/null +++ b/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/Utils.java @@ -0,0 +1,24 @@ +// Copyright Yahoo. 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.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/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..0cb5c9d4f82 --- /dev/null +++ b/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/package-info.java @@ -0,0 +1,8 @@ +// Copyright Yahoo. 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/java/com/yahoo/vespa/hosted/ca/Certificates.java b/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/ca/Certificates.java new file mode 100644 index 00000000000..df904bf8010 --- /dev/null +++ b/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/ca/Certificates.java @@ -0,0 +1,95 @@ +// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.hosted.ca; + +import com.yahoo.security.Pkcs10Csr; +import com.yahoo.security.SubjectAlternativeName; +import com.yahoo.security.X509CertificateBuilder; +import com.yahoo.security.X509CertificateUtils; +import com.yahoo.vespa.athenz.identityprovider.api.VespaUniqueInstanceId; + +import java.security.PrivateKey; +import java.security.cert.X509Certificate; +import java.time.Clock; +import java.time.Duration; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.stream.Collectors; + +import static com.yahoo.security.SignatureAlgorithm.SHA256_WITH_ECDSA; +import static com.yahoo.security.SubjectAlternativeName.Type.DNS; + +/** + * Helper class for creating {@link X509Certificate}s. + * + * @author mpolden + */ +public class Certificates { + + private static final Duration CERTIFICATE_TTL = Duration.ofDays(30); + private static final String INSTANCE_ID_DELIMITER = ".instanceid.athenz."; + + private final Clock clock; + + public Certificates(Clock clock) { + this.clock = Objects.requireNonNull(clock, "clock must be non-null"); + } + + /** Create a new certificate from csr signed by the given CA private key */ + public X509Certificate create(Pkcs10Csr csr, X509Certificate caCertificate, PrivateKey caPrivateKey) { + var x500principal = caCertificate.getSubjectX500Principal(); + var now = clock.instant(); + var notBefore = now.minus(Duration.ofHours(1)); + var notAfter = now.plus(CERTIFICATE_TTL); + var builder = X509CertificateBuilder.fromCsr(csr, + x500principal, + notBefore, + notAfter, + caPrivateKey, + SHA256_WITH_ECDSA, + X509CertificateBuilder.generateRandomSerialNumber()); + for (var san : csr.getSubjectAlternativeNames()) { + builder = builder.addSubjectAlternativeName(san.decode()); + } + return builder.build(); + } + + /** Returns instance ID parsed from the Subject Alternative Names in given csr */ + public static String instanceIdFrom(Pkcs10Csr csr) { + return getInstanceIdFromSAN(csr.getSubjectAlternativeNames()) + .orElseThrow(() -> new IllegalArgumentException("No instance ID found in CSR")); + } + + public static Optional<String> instanceIdFrom(X509Certificate certificate) { + return getInstanceIdFromSAN(X509CertificateUtils.getSubjectAlternativeNames(certificate)); + } + + private static Optional<String> getInstanceIdFromSAN(List<SubjectAlternativeName> subjectAlternativeNames) { + return subjectAlternativeNames.stream() + .filter(san -> san.getType() == DNS) + .map(SubjectAlternativeName::getValue) + .map(Certificates::parseInstanceId) + .flatMap(Optional::stream) + .map(VespaUniqueInstanceId::asDottedString) + .findFirst(); + } + + private static Optional<VespaUniqueInstanceId> parseInstanceId(String dnsName) { + var delimiterStart = dnsName.indexOf(INSTANCE_ID_DELIMITER); + if (delimiterStart == -1) return Optional.empty(); + dnsName = dnsName.substring(0, delimiterStart); + try { + return Optional.of(VespaUniqueInstanceId.fromDottedString(dnsName)); + } catch (IllegalArgumentException e) { + return Optional.empty(); + } + } + + public static String getSubjectAlternativeNames(Pkcs10Csr csr, SubjectAlternativeName.Type sanType) { + return csr.getSubjectAlternativeNames().stream() + .map(SubjectAlternativeName::decode) + .filter(san -> san.getType() == sanType) + .map(SubjectAlternativeName::getValue) + .collect(Collectors.joining(",")); + } +} diff --git a/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/ca/instance/InstanceIdentity.java b/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/ca/instance/InstanceIdentity.java new file mode 100644 index 00000000000..f33ec4fbd6d --- /dev/null +++ b/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/ca/instance/InstanceIdentity.java @@ -0,0 +1,64 @@ +// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.hosted.ca.instance; + +import java.security.cert.X509Certificate; +import java.util.Objects; +import java.util.Optional; + +/** + * A signed instance identity object that includes a client certificate. This is the result of a successful + * {@link InstanceRegistration} and is the same type as InstanceIdentity in the ZTS API. + * + * @author mpolden + */ +public class InstanceIdentity { + + private final String provider; + private final String service; + private final String instanceId; + private final Optional<X509Certificate> x509Certificate; + + public InstanceIdentity(String provider, String service, String instanceId, Optional<X509Certificate> x509Certificate) { + this.provider = Objects.requireNonNull(provider, "provider must be non-null"); + this.service = Objects.requireNonNull(service, "service must be non-null"); + this.instanceId = Objects.requireNonNull(instanceId, "instanceId must be non-null"); + this.x509Certificate = Objects.requireNonNull(x509Certificate, "x509Certificate must be non-null"); + } + + /** Same as {@link InstanceRegistration#domain()} */ + public String provider() { + return provider; + } + + /** Same as {@link InstanceRegistration#service()} ()} */ + public String service() { + return service; + } + + /** A unique identifier of the instance to which the certificate is issued */ + public String instanceId() { + return instanceId; + } + + /** The issued certificate */ + public Optional<X509Certificate> x509Certificate() { + return x509Certificate; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + InstanceIdentity that = (InstanceIdentity) o; + return provider.equals(that.provider) && + service.equals(that.service) && + instanceId.equals(that.instanceId) && + x509Certificate.equals(that.x509Certificate); + } + + @Override + public int hashCode() { + return Objects.hash(provider, service, instanceId, x509Certificate); + } + +} diff --git a/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/ca/instance/InstanceRefresh.java b/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/ca/instance/InstanceRefresh.java new file mode 100644 index 00000000000..d63ee7f979f --- /dev/null +++ b/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/ca/instance/InstanceRefresh.java @@ -0,0 +1,40 @@ +// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.hosted.ca.instance; + +import com.yahoo.security.Pkcs10Csr; + +import java.util.Objects; + +/** + * Information for refreshing a instance in the system. This is the same type as InstanceRefreshInformation type in + * the ZTS API. + * + * @author mpolden + */ +public class InstanceRefresh { + + private final Pkcs10Csr csr; + + public InstanceRefresh(Pkcs10Csr csr) { + this.csr = Objects.requireNonNull(csr, "csr must be non-null"); + } + + /** The Certificate Signed Request describing the wanted certificate */ + public Pkcs10Csr csr() { + return csr; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + InstanceRefresh that = (InstanceRefresh) o; + return csr.equals(that.csr); + } + + @Override + public int hashCode() { + return Objects.hash(csr); + } + +} diff --git a/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/ca/instance/InstanceRegistration.java b/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/ca/instance/InstanceRegistration.java new file mode 100644 index 00000000000..231954976bf --- /dev/null +++ b/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/ca/instance/InstanceRegistration.java @@ -0,0 +1,83 @@ +// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.hosted.ca.instance; + +import com.yahoo.security.Pkcs10Csr; +import com.yahoo.vespa.athenz.identityprovider.api.SignedIdentityDocument; + +import java.util.Objects; + +/** + * Information for registering a new instance in the system. This is the same type as InstanceRegisterInformation type + * in the ZTS API. + * + * @author mpolden + */ +public class InstanceRegistration { + + private final String provider; + private final String domain; + private final String service; + private final SignedIdentityDocument attestationData; + private final Pkcs10Csr csr; + + public InstanceRegistration(String provider, String domain, String service, SignedIdentityDocument attestationData, Pkcs10Csr csr) { + this.provider = Objects.requireNonNull(provider, "provider must be non-null"); + this.domain = Objects.requireNonNull(domain, "domain must be non-null"); + this.service = Objects.requireNonNull(service, "service must be non-null"); + this.attestationData = Objects.requireNonNull(attestationData, "attestationData must be non-null"); + this.csr = Objects.requireNonNull(csr, "csr must be non-null"); + } + + /** The provider which issued the attestation data contained in this */ + public String provider() { + return provider; + } + + /** Athenz domain of the instance */ + public String domain() { + return domain; + } + + /** Athenz service of the instance */ + public String service() { + return service; + } + + /** Host document describing this instance (received from config server) */ + public SignedIdentityDocument attestationData() { + return attestationData; + } + + /** The Certificate Signed Request describing the wanted certificate */ + public Pkcs10Csr csr() { + return csr; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + InstanceRegistration that = (InstanceRegistration) o; + return provider.equals(that.provider) && + domain.equals(that.domain) && + service.equals(that.service) && + attestationData.equals(that.attestationData) && + csr.equals(that.csr); + } + + @Override + public int hashCode() { + return Objects.hash(provider, domain, service, attestationData, csr); + } + + @Override + public String toString() { + return "InstanceRegistration{" + + "provider='" + provider + '\'' + + ", domain='" + domain + '\'' + + ", service='" + service + '\'' + + ", attestationData='" + attestationData.toString() + '\'' + + ", csr=" + csr.toString() + + '}'; + } +} diff --git a/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/ca/restapi/CertificateAuthorityApiHandler.java b/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/ca/restapi/CertificateAuthorityApiHandler.java new file mode 100644 index 00000000000..531a815922b --- /dev/null +++ b/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/ca/restapi/CertificateAuthorityApiHandler.java @@ -0,0 +1,198 @@ +// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.hosted.ca.restapi; + +import com.yahoo.component.annotation.Inject; +import com.yahoo.container.jdisc.HttpRequest; +import com.yahoo.container.jdisc.HttpResponse; +import com.yahoo.container.jdisc.ThreadedHttpRequestHandler; +import com.yahoo.container.jdisc.secretstore.SecretStore; +import com.yahoo.jdisc.http.server.jetty.RequestUtils; +import com.yahoo.restapi.ErrorResponse; +import com.yahoo.restapi.Path; +import com.yahoo.restapi.SlimeJsonResponse; +import com.yahoo.security.KeyUtils; +import com.yahoo.security.SubjectAlternativeName; +import com.yahoo.security.X509CertificateUtils; +import com.yahoo.slime.Slime; +import com.yahoo.slime.SlimeUtils; +import com.yahoo.vespa.athenz.api.AthenzService; +import com.yahoo.vespa.athenz.identityprovider.api.EntityBindingsMapper; +import com.yahoo.vespa.hosted.athenz.instanceproviderservice.InstanceConfirmation; +import com.yahoo.vespa.hosted.athenz.instanceproviderservice.InstanceValidator; +import com.yahoo.vespa.hosted.athenz.instanceproviderservice.config.AthenzProviderServiceConfig; +import com.yahoo.vespa.hosted.ca.Certificates; +import com.yahoo.vespa.hosted.ca.instance.InstanceIdentity; +import com.yahoo.vespa.hosted.ca.instance.InstanceRefresh; +import com.yahoo.yolean.Exceptions; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.security.PrivateKey; +import java.security.cert.X509Certificate; +import java.time.Clock; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.function.Function; +import java.util.logging.Level; + +/** + * REST API for issuing and refreshing node certificates in a hosted Vespa system. + * + * The API implements the following subset of methods from the Athenz ZTS REST API: + * + * - Instance registration + * - Instance refresh + * + * @author mpolden + */ +public class CertificateAuthorityApiHandler extends ThreadedHttpRequestHandler { + + private final SecretStore secretStore; + private final Certificates certificates; + private final String caPrivateKeySecretName; + private final String caCertificateSecretName; + private final InstanceValidator instanceValidator; + + @Inject + public CertificateAuthorityApiHandler(Context ctx, SecretStore secretStore, AthenzProviderServiceConfig athenzProviderServiceConfig, InstanceValidator instanceValidator) { + this(ctx, secretStore, new Certificates(Clock.systemUTC()), athenzProviderServiceConfig, instanceValidator); + } + + CertificateAuthorityApiHandler(Context ctx, SecretStore secretStore, Certificates certificates, AthenzProviderServiceConfig athenzProviderServiceConfig, InstanceValidator instanceValidator) { + super(ctx); + this.secretStore = secretStore; + this.certificates = certificates; + this.caPrivateKeySecretName = athenzProviderServiceConfig.secretName(); + this.caCertificateSecretName = athenzProviderServiceConfig.caCertSecretName(); + this.instanceValidator = instanceValidator; + } + + @Override + public HttpResponse handle(HttpRequest request) { + try { + switch (request.getMethod()) { + case POST: return handlePost(request); + default: return ErrorResponse.methodNotAllowed("Method " + request.getMethod() + " is unsupported"); + } + } catch (IllegalArgumentException e) { + return ErrorResponse.badRequest(request.getMethod() + " " + request.getUri() + " failed: " + Exceptions.toMessageString(e)); + } catch (RuntimeException e) { + log.log(Level.WARNING, "Unexpected error handling " + request.getMethod() + " " + request.getUri(), e); + return ErrorResponse.internalServerError(Exceptions.toMessageString(e)); + } + } + + private HttpResponse handlePost(HttpRequest request) { + Path path = new Path(request.getUri()); + if (path.matches("/ca/v1/instance/")) return registerInstance(request); + if (path.matches("/ca/v1/instance/{provider}/{domain}/{service}/{instanceId}")) return refreshInstance(request, path.get("provider"), path.get("service"), path.get("instanceId")); + return ErrorResponse.notFoundError("Nothing at " + path); + } + + private HttpResponse registerInstance(HttpRequest request) { + var instanceRegistration = deserializeRequest(request, InstanceSerializer::registrationFromSlime); + + InstanceConfirmation confirmation = new InstanceConfirmation(instanceRegistration.provider(), instanceRegistration.domain(), instanceRegistration.service(), EntityBindingsMapper.toSignedIdentityDocumentEntity(instanceRegistration.attestationData())); + confirmation.set(InstanceValidator.SAN_IPS_ATTRNAME, Certificates.getSubjectAlternativeNames(instanceRegistration.csr(), SubjectAlternativeName.Type.IP)); + confirmation.set(InstanceValidator.SAN_DNS_ATTRNAME, Certificates.getSubjectAlternativeNames(instanceRegistration.csr(), SubjectAlternativeName.Type.DNS)); + if (!instanceValidator.isValidInstance(confirmation)) { + log.log(Level.INFO, "Invalid instance registration for " + instanceRegistration.toString()); + return ErrorResponse.forbidden("Unable to launch service: " +instanceRegistration.service()); + } + var certificate = certificates.create(instanceRegistration.csr(), caCertificate(), caPrivateKey()); + var instanceId = Certificates.instanceIdFrom(instanceRegistration.csr()); + var identity = new InstanceIdentity(instanceRegistration.provider(), instanceRegistration.service(), instanceId, + Optional.of(certificate)); + return new SlimeJsonResponse(InstanceSerializer.identityToSlime(identity)); + } + + private HttpResponse refreshInstance(HttpRequest request, String provider, String service, String instanceId) { + var instanceRefresh = deserializeRequest(request, InstanceSerializer::refreshFromSlime); + var instanceIdFromCsr = Certificates.instanceIdFrom(instanceRefresh.csr()); + + var athenzService = getRequestAthenzService(request); + + if (!instanceIdFromCsr.equals(instanceId)) { + throw new IllegalArgumentException("Mismatch between instance ID in URL path and instance ID in CSR " + + "[instanceId=" + instanceId + ",instanceIdFromCsr=" + instanceIdFromCsr + + "]"); + } + + // Verify that the csr instance id matches one of the certificates in the chain + refreshesSameInstanceId(instanceIdFromCsr, request); + + + // Validate that there is no privilege escalation (can only refresh same service) + refreshesSameService(instanceRefresh, athenzService); + + InstanceConfirmation instanceConfirmation = new InstanceConfirmation(provider, athenzService.getDomain().getName(), athenzService.getName(), null); + instanceConfirmation.set(InstanceValidator.SAN_IPS_ATTRNAME, Certificates.getSubjectAlternativeNames(instanceRefresh.csr(), SubjectAlternativeName.Type.IP)); + instanceConfirmation.set(InstanceValidator.SAN_DNS_ATTRNAME, Certificates.getSubjectAlternativeNames(instanceRefresh.csr(), SubjectAlternativeName.Type.DNS)); + if(!instanceValidator.isValidRefresh(instanceConfirmation)) { + return ErrorResponse.forbidden("Unable to refresh cert: " + instanceRefresh.csr().getSubject().toString()); + } + + var certificate = certificates.create(instanceRefresh.csr(), caCertificate(), caPrivateKey()); + var identity = new InstanceIdentity(provider, service, instanceIdFromCsr, Optional.of(certificate)); + return new SlimeJsonResponse(InstanceSerializer.identityToSlime(identity)); + } + + public void refreshesSameInstanceId(String csrInstanceId, HttpRequest request) { + String certificateInstanceId = getRequestCertificateChain(request).stream() + .map(Certificates::instanceIdFrom) + .filter(Optional::isPresent) + .map(Optional::get) + .findAny().orElseThrow(() -> new IllegalArgumentException("No client certificate with instance id in request.")); + + if(! Objects.equals(certificateInstanceId, csrInstanceId)) { + throw new IllegalArgumentException("Mismatch between instance ID in client certificate and instance ID in CSR " + + "[instanceId=" + certificateInstanceId + ",instanceIdFromCsr=" + csrInstanceId + + "]"); + } + } + + private void refreshesSameService(InstanceRefresh instanceRefresh, AthenzService athenzService) { + List<String> commonNames = X509CertificateUtils.getCommonNames(instanceRefresh.csr().getSubject()); + if(commonNames.size() != 1 && !Objects.equals(commonNames.get(0), athenzService.getFullName())) { + throw new IllegalArgumentException(String.format("Invalid request, trying to refresh service %s using service %s.", instanceRefresh.csr().getSubject().getName(), athenzService.getFullName())); + } + } + + /** Returns CA certificate from secret store */ + private X509Certificate caCertificate() { + return X509CertificateUtils.fromPem(secretStore.getSecret(caCertificateSecretName)); + } + + private List<X509Certificate> getRequestCertificateChain(HttpRequest request) { + return Optional.ofNullable(request.getJDiscRequest().context().get(RequestUtils.JDISC_REQUEST_X509CERT)) + .map(X509Certificate[].class::cast) + .map(Arrays::asList) + .orElse(Collections.emptyList()); + } + + private AthenzService getRequestAthenzService(HttpRequest request) { + return getRequestCertificateChain(request).stream() + .findFirst() + .flatMap(X509CertificateUtils::getSubjectCommonName) + .map(AthenzService::new) + .orElseThrow(() -> new RuntimeException("No certificate found")); + } + + /** Returns CA private key from secret store */ + private PrivateKey caPrivateKey() { + return KeyUtils.fromPemEncodedPrivateKey(secretStore.getSecret(caPrivateKeySecretName)); + } + + private static <T> T deserializeRequest(HttpRequest request, Function<Slime, T> serializer) { + try { + var slime = SlimeUtils.jsonToSlime(request.getData().readAllBytes()); + return serializer.apply(slime); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + +} diff --git a/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/ca/restapi/InstanceSerializer.java b/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/ca/restapi/InstanceSerializer.java new file mode 100644 index 00000000000..fec03afab69 --- /dev/null +++ b/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/ca/restapi/InstanceSerializer.java @@ -0,0 +1,123 @@ +// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.hosted.ca.restapi; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.yahoo.security.Pkcs10CsrUtils; +import com.yahoo.security.X509CertificateUtils; +import com.yahoo.slime.ArrayTraverser; +import com.yahoo.slime.Cursor; +import com.yahoo.slime.Slime; +import com.yahoo.slime.SlimeUtils; +import com.yahoo.text.StringUtilities; +import com.yahoo.vespa.athenz.api.AthenzService; +import com.yahoo.vespa.athenz.identityprovider.api.ClusterType; +import com.yahoo.vespa.athenz.identityprovider.api.IdentityType; +import com.yahoo.vespa.athenz.identityprovider.api.SignedIdentityDocument; +import com.yahoo.vespa.athenz.identityprovider.api.VespaUniqueInstanceId; +import com.yahoo.vespa.hosted.ca.instance.InstanceIdentity; +import com.yahoo.vespa.hosted.ca.instance.InstanceRefresh; +import com.yahoo.vespa.hosted.ca.instance.InstanceRegistration; + +import java.io.IOException; +import java.time.Instant; +import java.util.HashSet; +import java.util.Set; + +/** + * @author mpolden + */ +public class InstanceSerializer { + + private static final String PROVIDER_FIELD = "provider"; + private static final String DOMAIN_FIELD = "domain"; + private static final String SERVICE_FIELD = "service"; + private static final String ATTESTATION_DATA_FIELD = "attestationData"; + private static final String CSR_FIELD = "csr"; + private static final String NAME_FIELD = "service"; + private static final String INSTANCE_ID_FIELD = "instanceId"; + private static final String X509_CERTIFICATE_FIELD = "x509Certificate"; + + private static final String IDD_SIGNATURE_FIELD = "signature"; + private static final String IDD_SIGNING_KEY_VERSION_FIELD = "signing-key-version"; + private static final String IDD_PROVIDER_UNIQUE_ID_FIELD = "provider-unique-id"; + private static final String IDD_PROVIDER_SERVICE_FIELD = "provider-service"; + private static final String IDD_DOCUMENT_VERSION_FIELD = "document-version"; + private static final String IDD_CONFIGSERVER_HOSTNAME_FIELD = "configserver-hostname"; + private static final String IDD_INSTANCE_HOSTNAME_FIELD = "instance-hostname"; + private static final String IDD_CREATED_AT_FIELD = "created-at"; + private static final String IDD_IPADDRESSES_FIELD = "ip-addresses"; + private static final String IDD_IDENTITY_TYPE_FIELD = "identity-type"; + private static final String IDD_CLUSTER_TYPE_FIELD = "cluster-type"; + + private static final ObjectMapper objectMapper = new ObjectMapper(); + static { + objectMapper.registerModule(new JavaTimeModule()); + } + + private InstanceSerializer() {} + + public static InstanceRegistration registrationFromSlime(Slime slime) { + Cursor root = slime.get(); + return new InstanceRegistration(requireField(PROVIDER_FIELD, root).asString(), + requireField(DOMAIN_FIELD, root).asString(), + requireField(SERVICE_FIELD, root).asString(), + attestationDataToIdentityDocument(StringUtilities.unescape(requireField(ATTESTATION_DATA_FIELD, root).asString())), + Pkcs10CsrUtils.fromPem(requireField(CSR_FIELD, root).asString())); + } + + public static InstanceRefresh refreshFromSlime(Slime slime) { + Cursor root = slime.get(); + return new InstanceRefresh(Pkcs10CsrUtils.fromPem(requireField(CSR_FIELD, root).asString())); + } + + public static Slime identityToSlime(InstanceIdentity identity) { + Slime slime = new Slime(); + Cursor root = slime.setObject(); + root.setString(PROVIDER_FIELD, identity.provider()); + root.setString(NAME_FIELD, identity.service()); + root.setString(INSTANCE_ID_FIELD, identity.instanceId()); + identity.x509Certificate() + .map(X509CertificateUtils::toPem) + .ifPresent(pem -> root.setString(X509_CERTIFICATE_FIELD, pem)); + return slime; + } + + public static SignedIdentityDocument attestationDataToIdentityDocument(String attestationData) { + Slime slime = SlimeUtils.jsonToSlime(attestationData); + Cursor root = slime.get(); + String signature = requireField(IDD_SIGNATURE_FIELD, root).asString(); + long signingKeyVersion = requireField(IDD_SIGNING_KEY_VERSION_FIELD, root).asLong(); + VespaUniqueInstanceId providerUniqueId = VespaUniqueInstanceId.fromDottedString(requireField(IDD_PROVIDER_UNIQUE_ID_FIELD, root).asString()); + AthenzService athenzService = new AthenzService(requireField(IDD_PROVIDER_SERVICE_FIELD, root).asString()); + long documentVersion = requireField(IDD_DOCUMENT_VERSION_FIELD, root).asLong(); + String configserverHostname = requireField(IDD_CONFIGSERVER_HOSTNAME_FIELD, root).asString(); + String instanceHostname = requireField(IDD_INSTANCE_HOSTNAME_FIELD, root).asString(); + double createdAtTimestamp = requireField(IDD_CREATED_AT_FIELD, root).asDouble(); + Instant createdAt = getJsr310Instant(createdAtTimestamp); + Set<String> ips = new HashSet<>(); + requireField(IDD_IPADDRESSES_FIELD, root).traverse((ArrayTraverser) (__, entry) -> ips.add(entry.asString())); + IdentityType identityType = IdentityType.fromId(requireField(IDD_IDENTITY_TYPE_FIELD, root).asString()); + var clusterTypeField = root.field(IDD_CLUSTER_TYPE_FIELD); + var clusterType = clusterTypeField.valid() ? ClusterType.from(clusterTypeField.asString()) : null; + + + return new SignedIdentityDocument(signature, (int)signingKeyVersion, providerUniqueId, athenzService, (int)documentVersion, + configserverHostname, instanceHostname, createdAt, ips, identityType, clusterType); + } + + private static Instant getJsr310Instant(double v) { + try { + return objectMapper.readValue(Double.toString(v), Instant.class); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + private static Cursor requireField(String fieldName, Cursor root) { + var field = root.field(fieldName); + if (!field.valid()) throw new IllegalArgumentException("Missing required field '" + fieldName + "'"); + return field; + } + +} diff --git a/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/ca/restapi/package-info.java b/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/ca/restapi/package-info.java new file mode 100644 index 00000000000..118f4b08c2a --- /dev/null +++ b/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/ca/restapi/package-info.java @@ -0,0 +1,8 @@ +// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +/** + * @author mpolden + */ +@ExportPackage +package com.yahoo.vespa.hosted.ca.restapi; + +import com.yahoo.osgi.annotation.ExportPackage; diff --git a/athenz-identity-provider-service/src/test/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/AutoGeneratedKeyProvider.java b/athenz-identity-provider-service/src/test/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/AutoGeneratedKeyProvider.java new file mode 100644 index 00000000000..67e5caa0c18 --- /dev/null +++ b/athenz-identity-provider-service/src/test/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/AutoGeneratedKeyProvider.java @@ -0,0 +1,37 @@ +// Copyright Yahoo. 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.yahoo.security.KeyAlgorithm; +import com.yahoo.security.KeyUtils; + +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.security.PublicKey; + +/** + * @author bjorncs + */ +public class AutoGeneratedKeyProvider implements KeyProvider { + + private final KeyPair keyPair; + + public AutoGeneratedKeyProvider() { + keyPair = KeyUtils.generateKeypair(KeyAlgorithm.RSA, 2048); + } + + @Override + public PrivateKey getPrivateKey(int version) { + return keyPair.getPrivate(); + } + + @Override + public PublicKey getPublicKey(int version) { + return keyPair.getPublic(); + } + + public KeyPair getKeyPair() { + return keyPair; + } +} diff --git a/athenz-identity-provider-service/src/test/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/IdentityDocumentGeneratorTest.java b/athenz-identity-provider-service/src/test/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/IdentityDocumentGeneratorTest.java new file mode 100644 index 00000000000..9205baff0fc --- /dev/null +++ b/athenz-identity-provider-service/src/test/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/IdentityDocumentGeneratorTest.java @@ -0,0 +1,102 @@ +// Copyright Yahoo. 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.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.InstanceName; +import com.yahoo.config.provision.NodeResources; +import com.yahoo.config.provision.NodeType; +import com.yahoo.config.provision.RegionName; +import com.yahoo.config.provision.SystemName; +import com.yahoo.config.provision.TenantName; +import com.yahoo.config.provision.Zone; +import com.yahoo.vespa.athenz.identityprovider.api.IdentityType; +import com.yahoo.vespa.athenz.identityprovider.api.SignedIdentityDocument; +import com.yahoo.vespa.athenz.identityprovider.api.VespaUniqueInstanceId; +import com.yahoo.vespa.athenz.identityprovider.client.IdentityDocumentSigner; +import com.yahoo.vespa.hosted.athenz.instanceproviderservice.config.AthenzProviderServiceConfig; +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.node.IP; +import com.yahoo.vespa.hosted.provision.node.Nodes; +import com.yahoo.vespa.hosted.provision.testutils.MockNodeFlavors; +import org.junit.jupiter.api.Test; + +import java.util.Optional; +import java.util.Set; + +import static com.yahoo.vespa.hosted.athenz.instanceproviderservice.TestUtils.getAthenzProviderConfig; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * @author valerijf + */ +public class IdentityDocumentGeneratorTest { + + private static final Zone ZONE = new Zone(SystemName.cd, Environment.dev, RegionName.from("us-north-1")); + + @Test + void generates_valid_identity_document() { + String parentHostname = "docker-host"; + String containerHostname = "docker-container"; + + 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"), Optional.empty()), + new NodeResources(1, 1, 1, 1), + Generation.initial(), + false); + Node parentNode = Node.create("ostkid", + IP.Config.ofEmptyPool(Set.of("127.0.0.1")), + parentHostname, + new MockNodeFlavors().getFlavorOrThrow("default"), + NodeType.host).build(); + Node containerNode = Node.reserve(Set.of("::1"), + containerHostname, + parentHostname, + new MockNodeFlavors().getFlavorOrThrow("default").resources(), + NodeType.tenant) + .allocation(allocation).build(); + NodeRepository nodeRepository = mock(NodeRepository.class); + Nodes nodes = mock(Nodes.class); + when(nodeRepository.nodes()).thenReturn(nodes); + + when(nodes.node(eq(parentHostname))).thenReturn(Optional.of(parentNode)); + when(nodes.node(eq(containerHostname))).thenReturn(Optional.of(containerNode)); + AutoGeneratedKeyProvider keyProvider = new AutoGeneratedKeyProvider(); + + String dnsSuffix = "vespa.dns.suffix"; + AthenzProviderServiceConfig config = getAthenzProviderConfig("domain", "service", dnsSuffix); + IdentityDocumentGenerator identityDocumentGenerator = + new IdentityDocumentGenerator(config, nodeRepository, ZONE, keyProvider); + SignedIdentityDocument signedIdentityDocument = identityDocumentGenerator.generateSignedIdentityDocument(containerHostname, IdentityType.TENANT); + + // Verify attributes + assertEquals(containerHostname, signedIdentityDocument.instanceHostname()); + + String environment = "dev"; + String region = "us-north-1"; + + VespaUniqueInstanceId expectedProviderUniqueId = + new VespaUniqueInstanceId(0, "default", "default", "application", "tenant", region, environment, IdentityType.TENANT); + assertEquals(expectedProviderUniqueId, signedIdentityDocument.providerUniqueId()); + + // Validate that container ips are present + assertTrue(signedIdentityDocument.ipAddresses().contains("::1")); + + IdentityDocumentSigner signer = new IdentityDocumentSigner(); + + // Validate signature + assertTrue(signer.hasValidSignature(signedIdentityDocument, keyProvider.getPublicKey(0))); + } +} diff --git a/athenz-identity-provider-service/src/test/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/InstanceValidatorTest.java b/athenz-identity-provider-service/src/test/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/InstanceValidatorTest.java new file mode 100644 index 00000000000..a7947aff283 --- /dev/null +++ b/athenz-identity-provider-service/src/test/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/InstanceValidatorTest.java @@ -0,0 +1,296 @@ +// Copyright Yahoo. 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.yahoo.component.Version; +import com.yahoo.config.model.api.ApplicationInfo; +import com.yahoo.config.model.api.HostInfo; +import com.yahoo.config.model.api.Model; +import com.yahoo.config.model.api.ServiceInfo; +import com.yahoo.config.model.api.SuperModel; +import com.yahoo.config.model.api.SuperModelProvider; +import com.yahoo.config.provision.ApplicationId; +import com.yahoo.config.provision.ClusterMembership; +import com.yahoo.config.provision.NodeResources; +import com.yahoo.config.provision.NodeType; +import com.yahoo.vespa.athenz.api.AthenzService; +import com.yahoo.vespa.athenz.identityprovider.api.ClusterType; +import com.yahoo.vespa.athenz.identityprovider.api.EntityBindingsMapper; +import com.yahoo.vespa.athenz.identityprovider.api.IdentityType; +import com.yahoo.vespa.athenz.identityprovider.api.SignedIdentityDocument; +import com.yahoo.vespa.athenz.identityprovider.api.VespaUniqueInstanceId; +import com.yahoo.vespa.athenz.identityprovider.client.IdentityDocumentSigner; +import com.yahoo.vespa.hosted.athenz.instanceproviderservice.InstanceValidator.ValidationException; +import com.yahoo.vespa.hosted.provision.Node; +import com.yahoo.vespa.hosted.provision.NodeList; +import com.yahoo.vespa.hosted.provision.NodeRepository; +import com.yahoo.vespa.hosted.provision.node.IP; +import com.yahoo.vespa.hosted.provision.node.Nodes; +import com.yahoo.vespa.hosted.provision.testutils.MockNodeFlavors; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import java.util.stream.Stream; + +import static com.yahoo.vespa.hosted.athenz.instanceproviderservice.InstanceValidator.SERVICE_PROPERTIES_DOMAIN_KEY; +import static com.yahoo.vespa.hosted.athenz.instanceproviderservice.InstanceValidator.SERVICE_PROPERTIES_SERVICE_KEY; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * @author valerijf + * @author bjorncs + * @author mortent + */ +public class InstanceValidatorTest { + + private final ApplicationId applicationId = ApplicationId.from("tenant", "application", "instance"); + private final String domain = "domain"; + private final String service = "service"; + + private final AthenzService vespaTenantDomain = new AthenzService("vespa.vespa.tenant"); + private final AutoGeneratedKeyProvider keyProvider = new AutoGeneratedKeyProvider(); + + @Test + void application_does_not_exist() { + SuperModelProvider superModelProvider = mockSuperModelProvider(); + InstanceValidator instanceValidator = new InstanceValidator(null, superModelProvider, null, null, vespaTenantDomain); + assertFalse(instanceValidator.isValidInstance(createRegisterInstanceConfirmation(applicationId, domain, service))); + } + + @Test + void application_does_not_have_domain_set() { + SuperModelProvider superModelProvider = mockSuperModelProvider( + mockApplicationInfo(applicationId, 5, Collections.emptyList())); + InstanceValidator instanceValidator = new InstanceValidator(null, superModelProvider, null, new IdentityDocumentSigner(), vespaTenantDomain); + + assertFalse(instanceValidator.isValidInstance(createRegisterInstanceConfirmation(applicationId, domain, service))); + } + + @Test + void application_has_wrong_domain() { + ServiceInfo serviceInfo = new ServiceInfo("serviceName", "type", Collections.emptyList(), + Collections.singletonMap(SERVICE_PROPERTIES_DOMAIN_KEY, "not-domain"), "confId", "hostName"); + + SuperModelProvider superModelProvider = mockSuperModelProvider( + mockApplicationInfo(applicationId, 5, Collections.singletonList(serviceInfo))); + InstanceValidator instanceValidator = new InstanceValidator(null, superModelProvider, null, null, vespaTenantDomain); + + assertFalse(instanceValidator.isValidInstance(createRegisterInstanceConfirmation(applicationId, domain, service))); + } + + @Test + void application_has_same_domain_and_service() { + Map<String, String> properties = new HashMap<>(); + properties.put(SERVICE_PROPERTIES_DOMAIN_KEY, domain); + properties.put(SERVICE_PROPERTIES_SERVICE_KEY, service); + + ServiceInfo serviceInfo = new ServiceInfo("serviceName", "type", Collections.emptyList(), + properties, "confId", "hostName"); + + SuperModelProvider superModelProvider = mockSuperModelProvider( + mockApplicationInfo(applicationId, 5, Collections.singletonList(serviceInfo))); + IdentityDocumentSigner signer = mock(IdentityDocumentSigner.class); + when(signer.hasValidSignature(any(), any())).thenReturn(true); + InstanceValidator instanceValidator = new InstanceValidator(mock(KeyProvider.class), superModelProvider, mockNodeRepo(), signer, vespaTenantDomain); + + assertTrue(instanceValidator.isValidInstance(createRegisterInstanceConfirmation(applicationId, domain, service))); + } + + @Test + void rejects_invalid_provider_unique_id_in_csr() { + SuperModelProvider superModelProvider = mockSuperModelProvider(); + InstanceValidator instanceValidator = new InstanceValidator(null, superModelProvider, null, null, vespaTenantDomain); + InstanceConfirmation instanceConfirmation = createRegisterInstanceConfirmation(applicationId, domain, service); + VespaUniqueInstanceId tamperedId = new VespaUniqueInstanceId(0, "default", "instance", "app", "tenant", "us-north-1", "dev", IdentityType.NODE); + instanceConfirmation.set("sanDNS", tamperedId.asDottedString() + ".instanceid.athenz.dev-us-north-1.vespa.yahoo.cloud"); + assertFalse(instanceValidator.isValidInstance(instanceConfirmation)); + } + + @Test + void rejects_unknown_ips_in_csr() { + NodeRepository nodeRepository = mockNodeRepo(); + InstanceValidator instanceValidator = new InstanceValidator(null, mockSuperModelProvider(), nodeRepository, null, vespaTenantDomain); + InstanceConfirmation instanceConfirmation = createRegisterInstanceConfirmation(applicationId, domain, service); + Set<String> nodeIp = nodeRepository.nodes().list().owner(applicationId).stream().findFirst() + .map(Node::ipConfig) + .map(IP.Config::primary) + .orElseThrow(() -> new RuntimeException("No ipaddress for mocked node")); + + List<String> ips = new ArrayList<>(nodeIp); + ips.add("::ff"); + instanceConfirmation.set("sanIP", String.join(",", ips)); + assertFalse(instanceValidator.isValidInstance(instanceConfirmation)); + } + + @Test + void rejects_invalid_cluster_type_in_csr() { + var props = Map.of(SERVICE_PROPERTIES_DOMAIN_KEY, domain, SERVICE_PROPERTIES_SERVICE_KEY, service); + var info = new ServiceInfo("serviceName", "type", List.of(), props, "confId", "hostName"); + var provider = mockSuperModelProvider(mockApplicationInfo(applicationId, 5, List.of(info))); + var instanceValidator = new InstanceValidator(keyProvider, provider, mockNodeRepo(), new IdentityDocumentSigner(), vespaTenantDomain); + var instanceConfirmation = createRegisterInstanceConfirmation(applicationId, domain, service); + instanceConfirmation.set("sanURI", "vespa://cluster-type/content"); + var exception = assertThrows(ValidationException.class, () -> instanceValidator.validateInstance(instanceConfirmation)); + var expectedMsg = "Illegal SAN URIs: expected '[vespa://cluster-type/container]' found '[vespa://cluster-type/content]'"; + assertEquals(expectedMsg, exception.getMessage()); + } + + @Test + void accepts_valid_refresh_requests() { + NodeRepository nodeRepository = mock(NodeRepository.class); + Nodes nodes = mock(Nodes.class); + when(nodeRepository.nodes()).thenReturn(nodes); + InstanceValidator instanceValidator = new InstanceValidator(null, null, nodeRepository, new IdentityDocumentSigner(), vespaTenantDomain); + + List<Node> nodeList = createNodes(10); + Node node = nodeList.get(0); + nodeList = allocateNode(nodeList, node, applicationId); + when(nodes.list()).thenReturn(NodeList.copyOf(nodeList)); + String nodeIp = node.ipConfig().primary().stream().findAny().orElseThrow(() -> new RuntimeException("No ipaddress for mocked node")); + InstanceConfirmation instanceConfirmation = createRefreshInstanceConfirmation(applicationId, domain, service, List.of(nodeIp)); + + assertTrue(instanceValidator.isValidRefresh(instanceConfirmation)); + } + + @Test + void rejects_refresh_on_ip_mismatch() { + NodeRepository nodeRepository = mockNodeRepo(); + InstanceValidator instanceValidator = new InstanceValidator(null, null, nodeRepository, new IdentityDocumentSigner(), vespaTenantDomain); + + Set<String> nodeIp = nodeRepository.nodes().list().owner(applicationId).stream().findFirst() + .map(Node::ipConfig) + .map(IP.Config::primary) + .orElseThrow(() -> new RuntimeException("No ipaddress for mocked node")); + + List<String> ips = new ArrayList<>(nodeIp); + ips.add("::ff"); + // Add invalid ip to list of ip addresses + InstanceConfirmation instanceConfirmation = createRefreshInstanceConfirmation(applicationId, domain, service, ips); + + assertFalse(instanceValidator.isValidRefresh(instanceConfirmation)); + } + + @Test + void rejects_refresh_when_node_is_not_allocated() { + NodeRepository nodeRepository = mock(NodeRepository.class); + Nodes nodes = mock(Nodes.class); + when(nodeRepository.nodes()).thenReturn(nodes); + + InstanceValidator instanceValidator = new InstanceValidator(null, null, nodeRepository, new IdentityDocumentSigner(), vespaTenantDomain); + + List<Node> nodeList = createNodes(10); + + when(nodes.list()).thenReturn(NodeList.copyOf(nodeList)); + InstanceConfirmation instanceConfirmation = createRefreshInstanceConfirmation(applicationId, domain, service, List.of("::11")); + + assertFalse(instanceValidator.isValidRefresh(instanceConfirmation)); + + } + + private NodeRepository mockNodeRepo() { + NodeRepository nodeRepository = mock(NodeRepository.class); + Nodes nodes = mock(Nodes.class); + when(nodeRepository.nodes()).thenReturn(nodes); + List<Node> nodeList = createNodes(10); + Node node = nodeList.get(0); + nodeList = allocateNode(nodeList, node, applicationId); + when(nodes.list()).thenReturn(NodeList.copyOf(nodeList)); + return nodeRepository; + } + + private InstanceConfirmation createRegisterInstanceConfirmation( + ApplicationId applicationId, String domain, String service) { + VespaUniqueInstanceId vespaUniqueInstanceId = new VespaUniqueInstanceId(0, "default", applicationId.instance().value(), applicationId.application().value(), applicationId.tenant().value(), "us-north-1", "dev", IdentityType.NODE); + var domainService = new AthenzService(domain, service); + var clock = Instant.now(); + var clusterType = ClusterType.CONTAINER; + var signature = new IdentityDocumentSigner() + .generateSignature( + vespaUniqueInstanceId, domainService, "localhost", "localhost", clock, Set.of(), + IdentityType.NODE, keyProvider.getPrivateKey(0)); + SignedIdentityDocument signedIdentityDocument = new SignedIdentityDocument( + signature, 0, vespaUniqueInstanceId, domainService, 0, "localhost", "localhost", + clock, Collections.emptySet(), IdentityType.NODE, clusterType); + return createInstanceConfirmation(vespaUniqueInstanceId, domain, service, signedIdentityDocument); + } + + private InstanceConfirmation createRefreshInstanceConfirmation(ApplicationId applicationId, String domain, String service, List<String> ips) { + VespaUniqueInstanceId vespaUniqueInstanceId = new VespaUniqueInstanceId(0, "default", applicationId.instance().value(), applicationId.application().value(), applicationId.tenant().value(), "us-north-1", "dev", IdentityType.NODE); + InstanceConfirmation instanceConfirmation = createInstanceConfirmation(vespaUniqueInstanceId, domain, service, null); + instanceConfirmation.set("sanIP", String.join(",", ips)); + return instanceConfirmation; + } + + private InstanceConfirmation createInstanceConfirmation(VespaUniqueInstanceId vespaUniqueInstanceId, String domain, String service, SignedIdentityDocument identityDocument) { + InstanceConfirmation instanceConfirmation = new InstanceConfirmation( + "vespa.vespa.cd.provider_dev_us-north-1", + domain, + service, + Optional.ofNullable(identityDocument) + .map(EntityBindingsMapper::toSignedIdentityDocumentEntity) + .orElse(null)); + instanceConfirmation.set("sanDNS", vespaUniqueInstanceId.asDottedString() + ".instanceid.athenz.dev-us-north-1.vespa.yahoo.cloud"); + instanceConfirmation.set("sanURI", "vespa://cluster-type/container"); + return instanceConfirmation; + } + + private SuperModelProvider mockSuperModelProvider(ApplicationInfo... appInfos) { + SuperModel superModel = new SuperModel(Stream.of(appInfos) + .collect(Collectors.toMap( + ApplicationInfo::getApplicationId, + Function.identity() + ) + ), + true); + + SuperModelProvider superModelProvider = mock(SuperModelProvider.class); + when(superModelProvider.getSuperModel()).thenReturn(superModel); + return superModelProvider; + } + + private ApplicationInfo mockApplicationInfo(ApplicationId appId, int numHosts, List<ServiceInfo> serviceInfo) { + List<HostInfo> hosts = IntStream.range(0, numHosts) + .mapToObj(i -> new HostInfo("host-" + i + "." + appId.toShortString() + ".yahoo.com", serviceInfo)) + .toList(); + + Model model = mock(Model.class); + when(model.getHosts()).thenReturn(hosts); + + return new ApplicationInfo(appId, 0, model); + } + + private List<Node> createNodes(int num) { + MockNodeFlavors flavors = new MockNodeFlavors(); + List<Node> nodeList = new ArrayList<>(); + for (int i = 0; i < num; i++) { + Node node = Node.create("foo" + i, new IP.Config(Set.of("::1" + i, "::2" + i, "::3" + i), Set.of()), + "foo" + i, flavors.getFlavorOrThrow("default"), NodeType.tenant).build(); + nodeList.add(node); + } + return nodeList; + } + + private List<Node> allocateNode(List<Node> nodeList, Node node, ApplicationId applicationId) { + nodeList.removeIf(n -> n.id().equals(node.id())); + nodeList.add(node.allocate(applicationId, + ClusterMembership.from("container/default/0/0", Version.fromString("6.123.4"), Optional.empty()), + new NodeResources(1, 1, 1, 1), + Instant.now())); + return nodeList; + } +} diff --git a/athenz-identity-provider-service/src/test/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/TestUtils.java b/athenz-identity-provider-service/src/test/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/TestUtils.java new file mode 100644 index 00000000000..4110ad2bfa2 --- /dev/null +++ b/athenz-identity-provider-service/src/test/java/com/yahoo/vespa/hosted/athenz/instanceproviderservice/TestUtils.java @@ -0,0 +1,27 @@ +// Copyright Yahoo. 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.yahoo.vespa.hosted.athenz.instanceproviderservice.config.AthenzProviderServiceConfig; + +/** + * @author bjorncs + */ +public class TestUtils { + + public static AthenzProviderServiceConfig getAthenzProviderConfig(String domain, + String service, + String dnsSuffix) { + AthenzProviderServiceConfig.Builder zoneConfig = + new AthenzProviderServiceConfig.Builder() + .serviceName(service) + .secretVersion(0) + .domain(domain) + .certDnsSuffix(dnsSuffix) + .ztsUrl("localhost/zts") + .secretName("s3cr3t") + .caCertSecretName(domain + ".ca.cert"); + return new AthenzProviderServiceConfig( + zoneConfig.athenzCaTrustStore("/dummy/path/to/athenz-ca.jks")); + } + +} diff --git a/athenz-identity-provider-service/src/test/java/com/yahoo/vespa/hosted/ca/CertificateTester.java b/athenz-identity-provider-service/src/test/java/com/yahoo/vespa/hosted/ca/CertificateTester.java new file mode 100644 index 00000000000..4012776949e --- /dev/null +++ b/athenz-identity-provider-service/src/test/java/com/yahoo/vespa/hosted/ca/CertificateTester.java @@ -0,0 +1,79 @@ +// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.hosted.ca; + +import com.yahoo.security.KeyAlgorithm; +import com.yahoo.security.KeyUtils; +import com.yahoo.security.Pkcs10Csr; +import com.yahoo.security.Pkcs10CsrBuilder; +import com.yahoo.security.SignatureAlgorithm; +import com.yahoo.security.SubjectAlternativeName; +import com.yahoo.security.X509CertificateBuilder; + +import javax.security.auth.x500.X500Principal; +import java.math.BigInteger; +import java.security.KeyPair; +import java.security.cert.X509Certificate; +import java.time.Duration; +import java.time.Instant; +import java.util.List; + +import static com.yahoo.security.SignatureAlgorithm.SHA256_WITH_ECDSA; + +/** + * Helper class for creating certificates, CSRs etc. for testing purposes. + * + * @author mpolden + */ +public class CertificateTester { + + private CertificateTester() {} + + public static X509Certificate createCertificate() { + var keyPair = KeyUtils.generateKeypair(KeyAlgorithm.EC, 256); + return createCertificate("subject", keyPair); + } + + public static X509Certificate createCertificate(String cn, KeyPair keyPair) { + var subject = new X500Principal("CN=" + cn); + return X509CertificateBuilder.fromKeypair(keyPair, + subject, + Instant.EPOCH, + Instant.EPOCH.plus(Duration.ofMinutes(1)), + SHA256_WITH_ECDSA, + BigInteger.ONE) + .build(); + } + + public static Pkcs10Csr createCsr() { + return createCsr(List.of(), List.of()); + } + + public static Pkcs10Csr createCsr(String dnsName) { + return createCsr(List.of(dnsName), List.of()); + } + + public static Pkcs10Csr createCsr(List<String> dnsNames) { + return createCsr(dnsNames, List.of()); + } + + public static Pkcs10Csr createCsr(String cn, List<String> dnsNames) { + return createCsr(cn, dnsNames, List.of()); + } + + public static Pkcs10Csr createCsr(List<String> dnsNames, List<String> ipAddresses) { + return createCsr("subject", dnsNames, ipAddresses); + } + public static Pkcs10Csr createCsr(String cn, List<String> dnsNames, List<String> ipAddresses) { + X500Principal subject = new X500Principal("CN=" + cn); + KeyPair keyPair = KeyUtils.generateKeypair(KeyAlgorithm.EC, 256); + var builder = Pkcs10CsrBuilder.fromKeypair(subject, keyPair, SignatureAlgorithm.SHA512_WITH_ECDSA); + for (var dnsName : dnsNames) { + builder = builder.addSubjectAlternativeName(SubjectAlternativeName.Type.DNS, dnsName); + } + for (var ipAddress : ipAddresses) { + builder = builder.addSubjectAlternativeName(SubjectAlternativeName.Type.IP, ipAddress); + } + return builder.build(); + } + +} diff --git a/athenz-identity-provider-service/src/test/java/com/yahoo/vespa/hosted/ca/CertificatesTest.java b/athenz-identity-provider-service/src/test/java/com/yahoo/vespa/hosted/ca/CertificatesTest.java new file mode 100644 index 00000000000..dd3ddeeb804 --- /dev/null +++ b/athenz-identity-provider-service/src/test/java/com/yahoo/vespa/hosted/ca/CertificatesTest.java @@ -0,0 +1,65 @@ +// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.hosted.ca; + +import com.yahoo.security.KeyAlgorithm; +import com.yahoo.security.KeyUtils; +import com.yahoo.security.SubjectAlternativeName; +import com.yahoo.test.ManualClock; +import org.junit.jupiter.api.Test; + +import java.security.KeyPair; +import java.security.cert.X509Certificate; +import java.time.Duration; +import java.util.List; + +import static java.time.temporal.ChronoUnit.SECONDS; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +/** + * @author mpolden + */ +public class CertificatesTest { + + private final KeyPair keyPair = KeyUtils.generateKeypair(KeyAlgorithm.EC, 256); + private final X509Certificate caCertificate = CertificateTester.createCertificate("CA", keyPair); + + @Test + void expiry() { + var clock = new ManualClock(); + var certificates = new Certificates(clock); + var csr = CertificateTester.createCsr(); + var certificate = certificates.create(csr, caCertificate, keyPair.getPrivate()); + var now = clock.instant(); + + assertEquals(now.minus(Duration.ofHours(1)).truncatedTo(SECONDS), certificate.getNotBefore().toInstant()); + assertEquals(now.plus(Duration.ofDays(30)).truncatedTo(SECONDS), certificate.getNotAfter().toInstant()); + } + + @Test + void add_san_from_csr() throws Exception { + var certificates = new Certificates(new ManualClock()); + var dnsName = "host.example.com"; + var ip = "192.0.2.42"; + var csr = CertificateTester.createCsr(List.of(dnsName), List.of(ip)); + var certificate = certificates.create(csr, caCertificate, keyPair.getPrivate()); + + assertNotNull(certificate.getSubjectAlternativeNames()); + assertEquals(2, certificate.getSubjectAlternativeNames().size()); + + var subjectAlternativeNames = List.copyOf(certificate.getSubjectAlternativeNames()); + assertEquals(List.of(SubjectAlternativeName.Type.DNS.getTag(), dnsName), + subjectAlternativeNames.get(0)); + assertEquals(List.of(SubjectAlternativeName.Type.IP.getTag(), ip), + subjectAlternativeNames.get(1)); + } + + @Test + void parse_instance_id() { + var instanceId = "1.cluster1.default.app1.tenant1.us-north-1.prod.node"; + var instanceIdWithSuffix = instanceId + ".instanceid.athenz.dev-us-north-1.vespa.aws.oath.cloud"; + var csr = CertificateTester.createCsr(List.of("foo", "bar", instanceIdWithSuffix)); + assertEquals(instanceId, Certificates.instanceIdFrom(csr)); + } + +} diff --git a/athenz-identity-provider-service/src/test/java/com/yahoo/vespa/hosted/ca/restapi/CertificateAuthorityApiTest.java b/athenz-identity-provider-service/src/test/java/com/yahoo/vespa/hosted/ca/restapi/CertificateAuthorityApiTest.java new file mode 100644 index 00000000000..bf2115e8759 --- /dev/null +++ b/athenz-identity-provider-service/src/test/java/com/yahoo/vespa/hosted/ca/restapi/CertificateAuthorityApiTest.java @@ -0,0 +1,243 @@ +// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.hosted.ca.restapi; + +import com.yahoo.application.container.handler.Request; +import com.yahoo.jdisc.http.server.jetty.RequestUtils; +import com.yahoo.security.KeyAlgorithm; +import com.yahoo.security.KeyUtils; +import com.yahoo.security.Pkcs10Csr; +import com.yahoo.security.Pkcs10CsrUtils; +import com.yahoo.security.X509CertificateUtils; +import com.yahoo.slime.SlimeUtils; +import com.yahoo.text.StringUtilities; +import com.yahoo.vespa.athenz.api.AthenzPrincipal; +import com.yahoo.vespa.athenz.api.AthenzService; +import com.yahoo.vespa.athenz.client.ErrorHandler; +import com.yahoo.vespa.athenz.client.zts.DefaultZtsClient; +import com.yahoo.vespa.hosted.ca.CertificateTester; +import org.apache.http.client.ResponseHandler; +import org.apache.http.client.methods.HttpUriRequest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import javax.net.ssl.SSLContext; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.security.Principal; +import java.security.cert.X509Certificate; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +/** + * @author mpolden + */ +public class CertificateAuthorityApiTest extends ContainerTester { + + private static final String INSTANCE_ID = "1.cluster1.default.app1.tenant1.us-north-1.prod.node"; + private static final String INSTANCE_ID_WITH_SUFFIX = INSTANCE_ID + ".instanceid.athenz.dev-us-north-1.vespa.aws.oath.cloud"; + private static final String INVALID_INSTANCE_ID = "1.cluster1.default.otherapp.othertenant.us-north-1.prod.node"; + private static final String INVALID_INSTANCE_ID_WITH_SUFFIX = INVALID_INSTANCE_ID + ".instanceid.athenz.dev-us-north-1.vespa.aws.oath.cloud"; + + private static final String CONTAINER_IDENTITY = "vespa.external.tenant"; + private static final String HOST_IDENTITY = "vespa.external.tenant-host"; + + @BeforeEach + public void before() { + setCaCertificateAndKey(); + } + + @Test + void register_instance() throws Exception { + // POST instance registration + var csr = CertificateTester.createCsr(List.of("node1.example.com", INSTANCE_ID_WITH_SUFFIX)); + assertIdentityResponse(new Request("http://localhost:12345/ca/v1/instance/", + instanceRegistrationJson(csr), + Request.Method.POST)); + + // POST instance registration with ZTS client + var ztsClient = new TestZtsClient(new AthenzPrincipal(new AthenzService(HOST_IDENTITY)), null, URI.create("http://localhost:12345/ca/v1/"), SSLContext.getDefault()); + var instanceIdentity = ztsClient.registerInstance(new AthenzService("vespa.external", "provider_prod_us-north-1"), + new AthenzService(CONTAINER_IDENTITY), + getAttestationData(), + csr); + assertEquals("CN=Vespa CA", instanceIdentity.certificate().getIssuerX500Principal().getName()); + } + + private X509Certificate registerInstance() throws Exception { + // POST instance registration + var csr = CertificateTester.createCsr(CONTAINER_IDENTITY, List.of("node1.example.com", INSTANCE_ID_WITH_SUFFIX)); + assertIdentityResponse(new Request("http://localhost:12345/ca/v1/instance/", + instanceRegistrationJson(csr), + Request.Method.POST)); + + // POST instance registration with ZTS client + var ztsClient = new TestZtsClient(new AthenzPrincipal(new AthenzService(HOST_IDENTITY)), null, URI.create("http://localhost:12345/ca/v1/"), SSLContext.getDefault()); + var instanceIdentity = ztsClient.registerInstance(new AthenzService("vespa.external", "provider_prod_us-north-1"), + new AthenzService(CONTAINER_IDENTITY), + getAttestationData(), + csr); + return instanceIdentity.certificate(); + } + + @Test + void refresh_instance() throws Exception { + // Register instance to get cert + var certificate = registerInstance(); + + // POST instance refresh + var principal = new AthenzPrincipal(new AthenzService(CONTAINER_IDENTITY)); + var csr = CertificateTester.createCsr(principal.getIdentity().getFullName(), List.of("node1.example.com", INSTANCE_ID_WITH_SUFFIX)); + var request = new Request("http://localhost:12345/ca/v1/instance/vespa.external.provider_prod_us-north-1/vespa.external/tenant/" + INSTANCE_ID, + instanceRefreshJson(csr), + Request.Method.POST, + principal); + request.getAttributes().put(RequestUtils.JDISC_REQUEST_X509CERT, new X509Certificate[]{certificate}); + assertIdentityResponse(request); + + // POST instance refresh with ZTS client + var ztsClient = new TestZtsClient(principal, certificate, URI.create("http://localhost:12345/ca/v1/"), SSLContext.getDefault()); + var instanceIdentity = ztsClient.refreshInstance(new AthenzService("vespa.external", "provider_prod_us-north-1"), + new AthenzService(CONTAINER_IDENTITY), + INSTANCE_ID, + csr); + assertEquals("CN=Vespa CA", instanceIdentity.certificate().getIssuerX500Principal().getName()); + } + + @Test + void invalid_requests() throws Exception { + // POST instance registration with missing fields + assertResponse(400, "{\"error-code\":\"BAD_REQUEST\",\"message\":\"POST http://localhost:12345/ca/v1/instance/ failed: Missing required field 'provider'\"}", + new Request("http://localhost:12345/ca/v1/instance/", + new byte[0], + Request.Method.POST)); + + // POST instance registration without DNS name in CSR + var csr = CertificateTester.createCsr(); + var request = new Request("http://localhost:12345/ca/v1/instance/", + instanceRegistrationJson(csr), + Request.Method.POST); + assertResponse(400, "{\"error-code\":\"BAD_REQUEST\",\"message\":\"POST http://localhost:12345/ca/v1/instance/ failed: No instance ID found in CSR\"}", request); + + // POST instance refresh with missing field + assertResponse(400, "{\"error-code\":\"BAD_REQUEST\",\"message\":\"POST http://localhost:12345/ca/v1/instance/vespa.external.provider_prod_us-north-1/vespa.external/tenant/1.cluster1.default.app1.tenant1.us-north-1.prod.node failed: Missing required field 'csr'\"}", + new Request("http://localhost:12345/ca/v1/instance/vespa.external.provider_prod_us-north-1/vespa.external/tenant/" + INSTANCE_ID, + new byte[0], + Request.Method.POST)); + + // POST instance refresh where instanceId does not match CSR dnsName + var principal = new AthenzPrincipal(new AthenzService(CONTAINER_IDENTITY)); + var cert = CertificateTester.createCertificate(CONTAINER_IDENTITY, KeyUtils.generateKeypair(KeyAlgorithm.EC)); + csr = CertificateTester.createCsr(principal.getIdentity().getFullName(), List.of("node1.example.com", INSTANCE_ID_WITH_SUFFIX)); + request = new Request("http://localhost:12345/ca/v1/instance/vespa.external.provider_prod_us-north-1/vespa.external/tenant/foobar", + instanceRefreshJson(csr), + Request.Method.POST, + principal); + request.getAttributes().put(RequestUtils.JDISC_REQUEST_X509CERT, new X509Certificate[]{cert}); + assertResponse( + 400, + "{\"error-code\":\"BAD_REQUEST\",\"message\":\"POST http://localhost:12345/ca/v1/instance/vespa.external.provider_prod_us-north-1/vespa.external/tenant/foobar failed: Mismatch between instance ID in URL path and instance ID in CSR [instanceId=foobar,instanceIdFromCsr=1.cluster1.default.app1.tenant1.us-north-1.prod.node]\"}", + request); + + // POST instance refresh using zts client where client cert does not contain instanceid + var certificate = registerInstance(); + var ztsClient = new TestZtsClient(principal, certificate, URI.create("http://localhost:12345/ca/v1/"), SSLContext.getDefault()); + try { + var invalidCsr = CertificateTester.createCsr(principal.getIdentity().getFullName(), List.of("node1.example.com", INVALID_INSTANCE_ID_WITH_SUFFIX)); + var instanceIdentity = ztsClient.refreshInstance(new AthenzService("vespa.external", "provider_prod_us-north-1"), + new AthenzService(CONTAINER_IDENTITY), + INSTANCE_ID, + invalidCsr); + fail("Refresh instance should have failed"); + } catch (Exception e) { + String expectedMessage = "Received error from ZTS: code=0, message=\"POST http://localhost:12345/ca/v1/instance/vespa.external.provider_prod_us-north-1/vespa.external/tenant/1.cluster1.default.app1.tenant1.us-north-1.prod.node failed: Mismatch between instance ID in URL path and instance ID in CSR [instanceId=1.cluster1.default.app1.tenant1.us-north-1.prod.node,instanceIdFromCsr=1.cluster1.default.otherapp.othertenant.us-north-1.prod.node]\""; + assertEquals(expectedMessage, e.getMessage()); + } + } + + private void setCaCertificateAndKey() { + var keyPair = KeyUtils.generateKeypair(KeyAlgorithm.EC, 256); + var caCertificatePem = X509CertificateUtils.toPem(CertificateTester.createCertificate("Vespa CA", keyPair)); + var privateKeyPem = KeyUtils.toPem(keyPair.getPrivate()); + secretStore().setSecret("vespa.external.ca.cert", caCertificatePem) + .setSecret("secretname", privateKeyPem); + } + + private void assertIdentityResponse(Request request) { + assertResponse(200, (body) -> { + var slime = SlimeUtils.jsonToSlime(body); + var root = slime.get(); + assertEquals("vespa.external.provider_prod_us-north-1", root.field("provider").asString()); + assertEquals("tenant", root.field("service").asString()); + assertEquals(INSTANCE_ID, root.field("instanceId").asString()); + var pemEncodedCertificate = root.field("x509Certificate").asString(); + assertTrue(pemEncodedCertificate.startsWith("-----BEGIN CERTIFICATE-----") && + pemEncodedCertificate.endsWith("-----END CERTIFICATE-----\n"), + "Response contains PEM certificate"); + }, request); + } + + private static byte[] instanceRefreshJson(Pkcs10Csr csr) { + var csrPem = Pkcs10CsrUtils.toPem(csr); + var json = "{\"csr\": \"" + csrPem + "\"}"; + return json.getBytes(StandardCharsets.UTF_8); + } + + private static byte[] instanceRegistrationJson(Pkcs10Csr csr) { + var csrPem = Pkcs10CsrUtils.toPem(csr); + var json = "{\n" + + " \"provider\": \"vespa.external.provider_prod_us-north-1\",\n" + + " \"domain\": \"vespa.external\",\n" + + " \"service\": \"tenant\",\n" + + " \"attestationData\": \""+getAttestationData()+"\",\n" + + " \"csr\": \"" + csrPem + "\"\n" + + "}"; + return json.getBytes(StandardCharsets.UTF_8); + } + + private static String getAttestationData () { + var json = "{\n" + + " \"signature\": \"SIGNATURE\",\n" + + " \"signing-key-version\": 0,\n" + + " \"provider-unique-id\": \"0.default.default.application.tenant.us-north-1.dev.tenant\",\n" + + " \"provider-service\": \"domain.service\",\n" + + " \"document-version\": 1,\n" + + " \"configserver-hostname\": \"localhost\",\n" + + " \"instance-hostname\": \"docker-container\",\n" + + " \"created-at\": 1572000079.00000,\n" + + " \"ip-addresses\": [\n" + + " \"::1\"\n" + + " ],\n" + + " \"identity-type\": \"tenant\"\n" + + "}"; + return StringUtilities.escape(json); + } + + /* + Zts client that adds principal as header (since setting up ssl in test is cumbersome) + */ + private static class TestZtsClient extends DefaultZtsClient { + + private final Principal principal; + private final X509Certificate certificate; + + public TestZtsClient(Principal principal, X509Certificate certificate, URI ztsUrl, SSLContext sslContext) { + super(ztsUrl, () -> sslContext, null, ErrorHandler.empty()); + this.principal = principal; + this.certificate = certificate; + } + + @Override + protected <T> T execute(HttpUriRequest request, ResponseHandler<T> responseHandler) { + request.addHeader("PRINCIPAL", principal.getName()); + Optional.ofNullable(certificate).ifPresent(cert -> { + var pem = X509CertificateUtils.toPem(certificate); + request.addHeader("CERTIFICATE", StringUtilities.escape(pem)); + }); + return super.execute(request, responseHandler); + } + } +} diff --git a/athenz-identity-provider-service/src/test/java/com/yahoo/vespa/hosted/ca/restapi/ContainerTester.java b/athenz-identity-provider-service/src/test/java/com/yahoo/vespa/hosted/ca/restapi/ContainerTester.java new file mode 100644 index 00000000000..8112f5779e5 --- /dev/null +++ b/athenz-identity-provider-service/src/test/java/com/yahoo/vespa/hosted/ca/restapi/ContainerTester.java @@ -0,0 +1,88 @@ +// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.hosted.ca.restapi; + +import com.yahoo.application.Networking; +import com.yahoo.application.container.JDisc; +import com.yahoo.application.container.handler.Request; +import com.yahoo.vespa.hosted.ca.restapi.mock.SecretStoreMock; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; + +import java.io.UncheckedIOException; +import java.nio.charset.CharacterCodingException; +import java.util.function.Consumer; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * The superclass of REST API tests which require a functional container instance. + * + * @author mpolden + */ +public class ContainerTester { + + private JDisc container; + + @BeforeEach + public void startContainer() { + container = JDisc.fromServicesXml(servicesXml(), Networking.enable); + } + + @AfterEach + public void stopContainer() { + container.close(); + } + + public SecretStoreMock secretStore() { + return (SecretStoreMock) container.components().getComponent(SecretStoreMock.class.getName()); + } + + public void assertResponse(int expectedStatus, String expectedBody, Request request) { + assertResponse(expectedStatus, (body) -> assertEquals(expectedBody, body), request); + } + + public void assertResponse(int expectedStatus, Consumer<String> bodyAsserter, Request request) { + var response = container.handleRequest(request); + try { + bodyAsserter.accept(response.getBodyAsString()); + } catch (CharacterCodingException e) { + throw new UncheckedIOException(e); + } + assertEquals(expectedStatus, response.getStatus()); + assertEquals("application/json; charset=UTF-8", response.getHeaders().getFirst("Content-Type")); + } + + private static String servicesXml() { + return "<container version='1.0'>\n" + + " <accesslog type=\"disabled\"/>\n" + + " <config name=\"container.handler.threadpool\">\n" + + " <maxthreads>10</maxthreads>\n" + + " </config>\n" + + " <config name='vespa.hosted.athenz.instanceproviderservice.config.athenz-provider-service'>\n" + + " <athenzCaTrustStore>/path/to/file</athenzCaTrustStore>\n" + + " <domain>vespa.external</domain>\n" + + " <serviceName>servicename</serviceName>\n" + + " <secretName>secretname</secretName>\n" + + " <secretVersion>0</secretVersion>\n" + + " <caCertSecretName>vespa.external.ca.cert</caCertSecretName>\n" + + " <certDnsSuffix>suffix</certDnsSuffix>\n" + + " <ztsUrl>https://localhost:123/</ztsUrl>\n" + + " </config>\n" + + " <component id='com.yahoo.vespa.hosted.ca.restapi.mock.SecretStoreMock'/>\n" + + " <component id='com.yahoo.vespa.hosted.ca.restapi.mock.InstanceValidatorMock'/>\n" + + " <handler id='com.yahoo.vespa.hosted.ca.restapi.CertificateAuthorityApiHandler'>\n" + + " <binding>http://*/ca/v1/*</binding>\n" + + " </handler>\n" + + " <http>\n" + + " <server id='default' port='12345'/>\n" + + " <filtering>\n" + + " <request-chain id=\"my-default-chain\">\n" + + " <filter id='com.yahoo.vespa.hosted.ca.restapi.mock.PrincipalFromHeaderFilter' />\n" + + " <binding>http://*/*</binding>\n" + + " </request-chain>\n" + + " </filtering>\n" + + " </http>\n" + + "</container>"; + } + +}
\ No newline at end of file diff --git a/athenz-identity-provider-service/src/test/java/com/yahoo/vespa/hosted/ca/restapi/InstanceSerializerTest.java b/athenz-identity-provider-service/src/test/java/com/yahoo/vespa/hosted/ca/restapi/InstanceSerializerTest.java new file mode 100644 index 00000000000..ca624918beb --- /dev/null +++ b/athenz-identity-provider-service/src/test/java/com/yahoo/vespa/hosted/ca/restapi/InstanceSerializerTest.java @@ -0,0 +1,99 @@ +// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.hosted.ca.restapi; + +import com.yahoo.security.Pkcs10CsrUtils; +import com.yahoo.security.X509CertificateUtils; +import com.yahoo.slime.Slime; +import com.yahoo.slime.SlimeUtils; +import com.yahoo.text.StringUtilities; +import com.yahoo.vespa.athenz.api.AthenzService; +import com.yahoo.vespa.athenz.identityprovider.api.ClusterType; +import com.yahoo.vespa.athenz.identityprovider.api.EntityBindingsMapper; +import com.yahoo.vespa.athenz.identityprovider.api.IdentityType; +import com.yahoo.vespa.athenz.identityprovider.api.SignedIdentityDocument; +import com.yahoo.vespa.athenz.identityprovider.api.VespaUniqueInstanceId; +import com.yahoo.vespa.hosted.ca.CertificateTester; +import com.yahoo.vespa.hosted.ca.instance.InstanceIdentity; +import com.yahoo.vespa.hosted.ca.instance.InstanceRefresh; +import com.yahoo.vespa.hosted.ca.instance.InstanceRegistration; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Collections; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * @author mpolden + */ +public class InstanceSerializerTest { + + @Test + void deserialize_instance_registration() { + var csr = CertificateTester.createCsr(); + var csrPem = Pkcs10CsrUtils.toPem(csr); + SignedIdentityDocument signedIdentityDocument = new SignedIdentityDocument( + "signature", + 0, + new VespaUniqueInstanceId(0, "cluster", "instance", "application", "tenant", "region", "prod", IdentityType.NODE), + new AthenzService("domain", "service"), + 0, + "configserverhostname", + "instancehostname", + Instant.now().truncatedTo(ChronoUnit.MICROS), // Truncate to the precision given from EntityBindingsMapper.toAttestationData() + Collections.emptySet(), + IdentityType.NODE, + ClusterType.CONTAINER); + + var json = String.format("{\n" + + " \"provider\": \"provider_prod_us-north-1\",\n" + + " \"domain\": \"vespa.external\",\n" + + " \"service\": \"tenant\",\n" + + " \"attestationData\":\"%s\",\n" + + " \"csr\": \"" + csrPem + "\"\n" + + "}", StringUtilities.escape(EntityBindingsMapper.toAttestationData(signedIdentityDocument))); + var instanceRegistration = new InstanceRegistration("provider_prod_us-north-1", "vespa.external", + "tenant", signedIdentityDocument, + csr); + var deserialized = InstanceSerializer.registrationFromSlime(SlimeUtils.jsonToSlime(json)); + assertEquals(instanceRegistration, deserialized); + } + + @Test + void serialize_instance_identity() { + var certificate = CertificateTester.createCertificate(); + var pem = X509CertificateUtils.toPem(certificate); + var identity = new InstanceIdentity("provider_prod_us-north-1", "tenant", "node1.example.com", + Optional.of(certificate)); + var json = "{" + + "\"provider\":\"provider_prod_us-north-1\"," + + "\"service\":\"tenant\"," + + "\"instanceId\":\"node1.example.com\"," + + "\"x509Certificate\":\"" + pem.replace("\n", "\\n") + "\"" + + "}"; + assertEquals(json, asJsonString(InstanceSerializer.identityToSlime(identity))); + } + + @Test + void serialize_instance_refresh() { + var csr = CertificateTester.createCsr(); + var csrPem = Pkcs10CsrUtils.toPem(csr); + var json = "{\"csr\": \"" + csrPem + "\"}"; + var instanceRefresh = new InstanceRefresh(csr); + var deserialized = InstanceSerializer.refreshFromSlime(SlimeUtils.jsonToSlime(json)); + assertEquals(instanceRefresh, deserialized); + } + + private static String asJsonString(Slime slime) { + try { + return new String(SlimeUtils.toJsonBytes(slime), StandardCharsets.UTF_8); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } +} diff --git a/athenz-identity-provider-service/src/test/java/com/yahoo/vespa/hosted/ca/restapi/mock/InstanceValidatorMock.java b/athenz-identity-provider-service/src/test/java/com/yahoo/vespa/hosted/ca/restapi/mock/InstanceValidatorMock.java new file mode 100644 index 00000000000..4151c1f15d7 --- /dev/null +++ b/athenz-identity-provider-service/src/test/java/com/yahoo/vespa/hosted/ca/restapi/mock/InstanceValidatorMock.java @@ -0,0 +1,27 @@ +// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.hosted.ca.restapi.mock; + +import com.yahoo.vespa.hosted.athenz.instanceproviderservice.InstanceConfirmation; +import com.yahoo.vespa.hosted.athenz.instanceproviderservice.InstanceValidator; + +/** + * @author mortent + */ +public class InstanceValidatorMock extends InstanceValidator { + + public InstanceValidatorMock() { + super(null, null, null, null, null); + } + + @Override + public boolean isValidInstance(InstanceConfirmation instanceConfirmation) { + return instanceConfirmation.attributes.get(SAN_DNS_ATTRNAME) != null && + instanceConfirmation.attributes.get(SAN_IPS_ATTRNAME) != null; + } + + @Override + public boolean isValidRefresh(InstanceConfirmation confirmation) { + return confirmation.attributes.get(SAN_DNS_ATTRNAME) != null && + confirmation.attributes.get(SAN_IPS_ATTRNAME) != null; + } +} diff --git a/athenz-identity-provider-service/src/test/java/com/yahoo/vespa/hosted/ca/restapi/mock/PrincipalFromHeaderFilter.java b/athenz-identity-provider-service/src/test/java/com/yahoo/vespa/hosted/ca/restapi/mock/PrincipalFromHeaderFilter.java new file mode 100644 index 00000000000..df98ba75dd2 --- /dev/null +++ b/athenz-identity-provider-service/src/test/java/com/yahoo/vespa/hosted/ca/restapi/mock/PrincipalFromHeaderFilter.java @@ -0,0 +1,34 @@ +// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.hosted.ca.restapi.mock; + +import com.yahoo.jdisc.handler.ResponseHandler; +import com.yahoo.jdisc.http.filter.DiscFilterRequest; +import com.yahoo.jdisc.http.filter.SecurityRequestFilter; +import com.yahoo.jdisc.http.server.jetty.RequestUtils; +import com.yahoo.security.X509CertificateUtils; +import com.yahoo.text.StringUtilities; +import com.yahoo.vespa.athenz.api.AthenzPrincipal; +import com.yahoo.vespa.athenz.api.AthenzService; + +import java.security.cert.X509Certificate; +import java.util.Optional; + +/** + * Read principal from http header + * + * @author mortent + */ +public class PrincipalFromHeaderFilter implements SecurityRequestFilter { + + @Override + public void filter(DiscFilterRequest request, ResponseHandler handler) { + String principal = request.getHeader("PRINCIPAL"); + request.setUserPrincipal(new AthenzPrincipal(new AthenzService(principal))); + + Optional<String> certificate = Optional.ofNullable(request.getHeader("CERTIFICATE")); + certificate.ifPresent(cert -> { + var x509cert = X509CertificateUtils.fromPem(StringUtilities.unescape(cert)); + request.setAttribute(RequestUtils.JDISC_REQUEST_X509CERT, new X509Certificate[]{x509cert}); + }); + } +} diff --git a/athenz-identity-provider-service/src/test/java/com/yahoo/vespa/hosted/ca/restapi/mock/SecretStoreMock.java b/athenz-identity-provider-service/src/test/java/com/yahoo/vespa/hosted/ca/restapi/mock/SecretStoreMock.java new file mode 100644 index 00000000000..5a9f4fd0b76 --- /dev/null +++ b/athenz-identity-provider-service/src/test/java/com/yahoo/vespa/hosted/ca/restapi/mock/SecretStoreMock.java @@ -0,0 +1,34 @@ +// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.hosted.ca.restapi.mock; + +import com.yahoo.component.AbstractComponent; +import com.yahoo.container.jdisc.secretstore.SecretStore; + +import java.util.HashMap; +import java.util.Map; + +/** + * @author mpolden + */ +public class SecretStoreMock extends AbstractComponent implements SecretStore { + + private final Map<String, String> secrets = new HashMap<>(); + + public SecretStoreMock setSecret(String key, String value) { + secrets.put(key, value); + return this; + } + + @Override + public String getSecret(String key) { + if (!secrets.containsKey(key)) throw new RuntimeException("No such key '" + key + "'"); + return secrets.get(key); + } + + @Override + public String getSecret(String key, int version) { + if (!secrets.containsKey(key)) throw new RuntimeException("No such key '" + key + "'"); + return secrets.get(key); + } + +} diff --git a/configdefinitions/src/vespa/athenz-provider-service.def b/configdefinitions/src/vespa/athenz-provider-service.def index 4c9c74f9b8f..2131aa88d30 100644 --- a/configdefinitions/src/vespa/athenz-provider-service.def +++ b/configdefinitions/src/vespa/athenz-provider-service.def @@ -13,11 +13,6 @@ secretName string # Secret version secretVersion int -# Tempory resources -sisSecretName string default="" -sisSecretVersion int default=0 -sisUrl string default = "" - # Secret name of CA certificate caCertSecretName string diff --git a/configserver/CMakeLists.txt b/configserver/CMakeLists.txt index 201d419c669..f189dc4f2c1 100644 --- a/configserver/CMakeLists.txt +++ b/configserver/CMakeLists.txt @@ -12,6 +12,7 @@ install(DIRECTORY DESTINATION conf/configserver) install(DIRECTORY DESTINATION conf/configserver-app/components) install(DIRECTORY DESTINATION conf/configserver-app/config-models) +install_symlink(lib/jars/athenz-identity-provider-service-jar-with-dependencies.jar conf/configserver-app/components/athenz-identity-provider-service.jar) install_symlink(lib/jars/config-model-fat.jar conf/configserver-app/components/config-model-fat.jar) install_symlink(lib/jars/configserver-flags-jar-with-dependencies.jar conf/configserver-app/components/configserver-flags.jar) install_symlink(lib/jars/flags-jar-with-dependencies.jar conf/configserver-app/components/flags.jar) diff --git a/dist/vespa.spec b/dist/vespa.spec index 0dee4e43910..58c2a18d3c1 100644 --- a/dist/vespa.spec +++ b/dist/vespa.spec @@ -586,6 +586,7 @@ fi %{_prefix}/include %dir %{_prefix}/lib %dir %{_prefix}/lib/jars +%{_prefix}/lib/jars/athenz-identity-provider-service-jar-with-dependencies.jar %{_prefix}/lib/jars/cloud-tenant-cd-jar-with-dependencies.jar %{_prefix}/lib/jars/clustercontroller-apps-jar-with-dependencies.jar %{_prefix}/lib/jars/clustercontroller-core-jar-with-dependencies.jar diff --git a/flags/src/main/java/com/yahoo/vespa/flags/Flags.java b/flags/src/main/java/com/yahoo/vespa/flags/Flags.java index c3099c48014..f4584f564ad 100644 --- a/flags/src/main/java/com/yahoo/vespa/flags/Flags.java +++ b/flags/src/main/java/com/yahoo/vespa/flags/Flags.java @@ -346,13 +346,6 @@ public class Flags { "Takes effect at redeployment", APPLICATION_ID); - public static final UnboundBooleanFlag VESPA_ATHENZ_PROVIDER = defineFeatureFlag( - "vespa-athenz-provider", false, - List.of("mortent"), "2023-02-22", "2023-05-01", - "Enable athenz provider in public systems", - "Takes effect on next config server container start", - ZONE_ID); - /** WARNING: public for testing: All flags should be defined in {@link Flags}. */ public static UnboundBooleanFlag defineFeatureFlag(String flagId, boolean defaultValue, List<String> owners, String createdAt, String expiresAt, String description, diff --git a/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/maintenance/identity/AthenzCredentialsMaintainer.java b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/maintenance/identity/AthenzCredentialsMaintainer.java index 6bd7d98e207..fc49dcc744c 100644 --- a/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/maintenance/identity/AthenzCredentialsMaintainer.java +++ b/node-admin/src/main/java/com/yahoo/vespa/hosted/node/admin/maintenance/identity/AthenzCredentialsMaintainer.java @@ -41,7 +41,6 @@ import java.time.Clock; import java.time.Duration; import java.time.Instant; import java.util.Map; -import java.util.Optional; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.logging.Level; @@ -190,9 +189,11 @@ public class AthenzCredentialsMaintainer implements CredentialsMaintainer { Pkcs10Csr csr = csrGenerator.generateInstanceCsr( context.identity(), doc.providerUniqueId(), doc.ipAddresses(), doc.clusterType(), keyPair); - // Allow all zts hosts while removing SIS - HostnameVerifier ztsHostNameVerifier = (hostname, sslSession) -> true; - try (ZtsClient ztsClient = new DefaultZtsClient.Builder(ztsEndpoint(doc)).withIdentityProvider(hostIdentityProvider).withHostnameVerifier(ztsHostNameVerifier).build()) { + // Set up a hostname verified for zts if this is configured to use the config server (internal zts) apis + HostnameVerifier ztsHostNameVerifier = useInternalZts + ? new AthenzIdentityVerifier(Set.of(configserverIdentity)) + : null; + try (ZtsClient ztsClient = new DefaultZtsClient.Builder(ztsEndpoint).withIdentityProvider(hostIdentityProvider).withHostnameVerifier(ztsHostNameVerifier).build()) { InstanceIdentity instanceIdentity = ztsClient.registerInstance( configserverIdentity, @@ -205,15 +206,6 @@ public class AthenzCredentialsMaintainer implements CredentialsMaintainer { } } - /** - * Return zts url from identity document, fallback to ztsEndpoint - */ - private URI ztsEndpoint(SignedIdentityDocument doc) { - return Optional.ofNullable(doc.ztsUrl()) - .filter(s -> !s.isBlank()) - .map(URI::create) - .orElse(ztsEndpoint); - } private void refreshIdentity(NodeAgentContext context, ContainerPath privateKeyFile, ContainerPath certificateFile, ContainerPath identityDocumentFile, SignedIdentityDocument doc) { KeyPair keyPair = KeyUtils.generateKeypair(KeyAlgorithm.RSA); @@ -225,9 +217,11 @@ public class AthenzCredentialsMaintainer implements CredentialsMaintainer { .build(); try { - // Allow all zts hosts while removing SIS - HostnameVerifier ztsHostNameVerifier = (hostname, sslSession) -> true; - try (ZtsClient ztsClient = new DefaultZtsClient.Builder(ztsEndpoint(doc)).withSslContext(containerIdentitySslContext).withHostnameVerifier(ztsHostNameVerifier).build()) { + // Set up a hostname verified for zts if this is configured to use the config server (internal zts) apis + HostnameVerifier ztsHostNameVerifier = useInternalZts + ? new AthenzIdentityVerifier(Set.of(configserverIdentity)) + : null; + try (ZtsClient ztsClient = new DefaultZtsClient.Builder(ztsEndpoint).withSslContext(containerIdentitySslContext).withHostnameVerifier(ztsHostNameVerifier).build()) { InstanceIdentity instanceIdentity = ztsClient.refreshInstance( configserverIdentity, @@ -27,6 +27,7 @@ <module>airlift-zstd</module> <module>application</module> <module>application-model</module> + <module>athenz-identity-provider-service</module> <module>bundle-plugin-test</module> <module>client</module> <module>cloud-tenant-base</module> diff --git a/vespa-athenz/src/main/java/com/yahoo/vespa/athenz/identityprovider/api/EntityBindingsMapper.java b/vespa-athenz/src/main/java/com/yahoo/vespa/athenz/identityprovider/api/EntityBindingsMapper.java index 2d77d2ceda1..9b7b666e353 100644 --- a/vespa-athenz/src/main/java/com/yahoo/vespa/athenz/identityprovider/api/EntityBindingsMapper.java +++ b/vespa-athenz/src/main/java/com/yahoo/vespa/athenz/identityprovider/api/EntityBindingsMapper.java @@ -4,10 +4,8 @@ package com.yahoo.vespa.athenz.identityprovider.api; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; -import com.yahoo.vespa.athenz.api.AthenzIdentity; import com.yahoo.vespa.athenz.api.AthenzService; import com.yahoo.vespa.athenz.identityprovider.api.bindings.SignedIdentityDocumentEntity; -import com.yahoo.vespa.athenz.utils.AthenzIdentities; import java.io.IOException; import java.io.InputStream; @@ -60,8 +58,6 @@ public class EntityBindingsMapper { entity.ipAddresses(), IdentityType.fromId(entity.identityType()), Optional.ofNullable(entity.clusterType()).map(ClusterType::from).orElse(null), - entity.ztsUrl(), - Optional.ofNullable(entity.serviceIdentity()).map(AthenzIdentities::from).orElse(null), entity.unknownAttributes()); } @@ -78,8 +74,6 @@ public class EntityBindingsMapper { model.ipAddresses(), model.identityType().id(), Optional.ofNullable(model.clusterType()).map(ClusterType::toConfigValue).orElse(null), - model.ztsUrl(), - Optional.ofNullable(model.serviceIdentity()).map(AthenzIdentity::getFullName).orElse(null), model.unknownAttributes()); } diff --git a/vespa-athenz/src/main/java/com/yahoo/vespa/athenz/identityprovider/api/SignedIdentityDocument.java b/vespa-athenz/src/main/java/com/yahoo/vespa/athenz/identityprovider/api/SignedIdentityDocument.java index ac37bb3368e..49a39d25e87 100644 --- a/vespa-athenz/src/main/java/com/yahoo/vespa/athenz/identityprovider/api/SignedIdentityDocument.java +++ b/vespa-athenz/src/main/java/com/yahoo/vespa/athenz/identityprovider/api/SignedIdentityDocument.java @@ -1,10 +1,8 @@ // Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. package com.yahoo.vespa.athenz.identityprovider.api; -import com.yahoo.vespa.athenz.api.AthenzIdentity; import com.yahoo.vespa.athenz.api.AthenzService; -import java.net.URL; import java.time.Instant; import java.util.HashMap; import java.util.Map; @@ -19,8 +17,7 @@ import java.util.Set; public record SignedIdentityDocument(String signature, int signingKeyVersion, VespaUniqueInstanceId providerUniqueId, AthenzService providerService, int documentVersion, String configServerHostname, String instanceHostname, Instant createdAt, Set<String> ipAddresses, - IdentityType identityType, ClusterType clusterType, String ztsUrl, - AthenzIdentity serviceIdentity, Map<String, Object> unknownAttributes) { + IdentityType identityType, ClusterType clusterType, Map<String, Object> unknownAttributes) { public SignedIdentityDocument { ipAddresses = Set.copyOf(ipAddresses); @@ -36,12 +33,12 @@ public record SignedIdentityDocument(String signature, int signingKeyVersion, Ve public SignedIdentityDocument(String signature, int signingKeyVersion, VespaUniqueInstanceId providerUniqueId, AthenzService providerService, int documentVersion, String configServerHostname, String instanceHostname, Instant createdAt, Set<String> ipAddresses, - IdentityType identityType, ClusterType clusterType, String ztsUrl, AthenzIdentity serviceIdentity) { + IdentityType identityType, ClusterType clusterType) { this(signature, signingKeyVersion, providerUniqueId, providerService, documentVersion, configServerHostname, - instanceHostname, createdAt, ipAddresses, identityType, clusterType, ztsUrl, serviceIdentity, Map.of()); + instanceHostname, createdAt, ipAddresses, identityType, clusterType, Map.of()); } - public static final int DEFAULT_DOCUMENT_VERSION = 3; + public static final int DEFAULT_DOCUMENT_VERSION = 2; public boolean outdated() { return documentVersion < DEFAULT_DOCUMENT_VERSION; } diff --git a/vespa-athenz/src/main/java/com/yahoo/vespa/athenz/identityprovider/api/bindings/SignedIdentityDocumentEntity.java b/vespa-athenz/src/main/java/com/yahoo/vespa/athenz/identityprovider/api/bindings/SignedIdentityDocumentEntity.java index fc0dff3b97b..c37dd2f9147 100644 --- a/vespa-athenz/src/main/java/com/yahoo/vespa/athenz/identityprovider/api/bindings/SignedIdentityDocumentEntity.java +++ b/vespa-athenz/src/main/java/com/yahoo/vespa/athenz/identityprovider/api/bindings/SignedIdentityDocumentEntity.java @@ -4,7 +4,6 @@ package com.yahoo.vespa.athenz.identityprovider.api.bindings; import com.fasterxml.jackson.annotation.JsonAnyGetter; import com.fasterxml.jackson.annotation.JsonAnySetter; import com.fasterxml.jackson.annotation.JsonCreator; -import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import java.time.Instant; @@ -15,11 +14,10 @@ import java.util.Set; /** * @author bjorncs */ -@JsonInclude(JsonInclude.Include.NON_NULL) public record SignedIdentityDocumentEntity( String signature, int signingKeyVersion, String providerUniqueId, String providerService, int documentVersion, String configServerHostname, String instanceHostname, Instant createdAt, Set<String> ipAddresses, - String identityType, String clusterType, String ztsUrl, String serviceIdentity, Map<String, Object> unknownAttributes) { + String identityType, String clusterType, Map<String, Object> unknownAttributes) { @JsonCreator public SignedIdentityDocumentEntity(@JsonProperty("signature") String signature, @@ -32,11 +30,9 @@ public record SignedIdentityDocumentEntity( @JsonProperty("created-at") Instant createdAt, @JsonProperty("ip-addresses") Set<String> ipAddresses, @JsonProperty("identity-type") String identityType, - @JsonProperty("cluster-type") String clusterType, - @JsonProperty("zts-url") String ztsUrl, - @JsonProperty("service-identity") String serviceIdentity) { + @JsonProperty("cluster-type") String clusterType) { this(signature, signingKeyVersion, providerUniqueId, providerService, documentVersion, configServerHostname, - instanceHostname, createdAt, ipAddresses, identityType, clusterType, ztsUrl, serviceIdentity, new HashMap<>()); + instanceHostname, createdAt, ipAddresses, identityType, clusterType, new HashMap<>()); } @JsonProperty("signature") @Override public String signature() { return signature; } @@ -50,8 +46,6 @@ public record SignedIdentityDocumentEntity( @JsonProperty("ip-addresses") @Override public Set<String> ipAddresses() { return ipAddresses; } @JsonProperty("identity-type") @Override public String identityType() { return identityType; } @JsonProperty("cluster-type") @Override public String clusterType() { return clusterType; } - @JsonProperty("zts-url") @Override public String ztsUrl() { return ztsUrl; } - @JsonProperty("service-identity") @Override public String serviceIdentity() { return serviceIdentity; } @JsonAnyGetter @Override public Map<String, Object> unknownAttributes() { return unknownAttributes; } @JsonAnySetter public void set(String name, Object value) { unknownAttributes.put(name, value); } } diff --git a/vespa-athenz/src/main/java/com/yahoo/vespa/athenz/identityprovider/client/IdentityDocumentSigner.java b/vespa-athenz/src/main/java/com/yahoo/vespa/athenz/identityprovider/client/IdentityDocumentSigner.java index 019f73fc6bf..14d06fe83f2 100644 --- a/vespa-athenz/src/main/java/com/yahoo/vespa/athenz/identityprovider/client/IdentityDocumentSigner.java +++ b/vespa-athenz/src/main/java/com/yahoo/vespa/athenz/identityprovider/client/IdentityDocumentSigner.java @@ -2,7 +2,6 @@ package com.yahoo.vespa.athenz.identityprovider.client; import com.yahoo.security.SignatureUtils; -import com.yahoo.vespa.athenz.api.AthenzIdentity; import com.yahoo.vespa.athenz.api.AthenzService; import com.yahoo.vespa.athenz.identityprovider.api.IdentityType; import com.yahoo.vespa.athenz.identityprovider.api.SignedIdentityDocument; @@ -19,7 +18,6 @@ import java.util.Base64; import java.util.Set; import java.util.TreeSet; -import static com.yahoo.vespa.athenz.identityprovider.api.SignedIdentityDocument.DEFAULT_DOCUMENT_VERSION; import static java.nio.charset.StandardCharsets.UTF_8; /** @@ -37,15 +35,13 @@ public class IdentityDocumentSigner { Instant createdAt, Set<String> ipAddresses, IdentityType identityType, - PrivateKey privateKey, - AthenzIdentity serviceIdentity) { + PrivateKey privateKey) { try { Signature signer = SignatureUtils.createSigner(privateKey); signer.initSign(privateKey); writeToSigner( signer, providerUniqueId, providerService, configServerHostname, instanceHostname, createdAt, ipAddresses, identityType); - writeToSigner(signer, serviceIdentity); byte[] signature = signer.sign(); return Base64.getEncoder().encodeToString(signature); } catch (GeneralSecurityException e) { @@ -60,9 +56,6 @@ public class IdentityDocumentSigner { writeToSigner( signer, doc.providerUniqueId(), doc.providerService(), doc.configServerHostname(), doc.instanceHostname(), doc.createdAt(), doc.ipAddresses(), doc.identityType()); - if (doc.documentVersion() >= DEFAULT_DOCUMENT_VERSION) { - writeToSigner(signer, doc.serviceIdentity()); - } return signer.verify(Base64.getDecoder().decode(doc.signature())); } catch (GeneralSecurityException e) { throw new RuntimeException(e); @@ -89,8 +82,4 @@ public class IdentityDocumentSigner { } signer.update(identityType.id().getBytes(UTF_8)); } - - private static void writeToSigner(Signature signer, AthenzIdentity serviceIdentity) throws SignatureException{ - signer.update(serviceIdentity.getFullName().getBytes(UTF_8)); - } } diff --git a/vespa-athenz/src/test/java/com/yahoo/vespa/athenz/identityprovider/api/EntityBindingsMapperTest.java b/vespa-athenz/src/test/java/com/yahoo/vespa/athenz/identityprovider/api/EntityBindingsMapperTest.java index 2a68f6fd231..f8c119190a6 100644 --- a/vespa-athenz/src/test/java/com/yahoo/vespa/athenz/identityprovider/api/EntityBindingsMapperTest.java +++ b/vespa-athenz/src/test/java/com/yahoo/vespa/athenz/identityprovider/api/EntityBindingsMapperTest.java @@ -30,7 +30,6 @@ class EntityBindingsMapperTest { "ip-addresses": [], "identity-type": "node", "cluster-type": "admin", - "zts-url": "https://zts.url/", "unknown-string": "string-value", "unknown-object": { "member-in-unknown-object": 123 } } diff --git a/vespa-athenz/src/test/java/com/yahoo/vespa/athenz/identityprovider/client/IdentityDocumentSignerTest.java b/vespa-athenz/src/test/java/com/yahoo/vespa/athenz/identityprovider/client/IdentityDocumentSignerTest.java index ff85cb79f02..0b8ff4277f1 100644 --- a/vespa-athenz/src/test/java/com/yahoo/vespa/athenz/identityprovider/client/IdentityDocumentSignerTest.java +++ b/vespa-athenz/src/test/java/com/yahoo/vespa/athenz/identityprovider/client/IdentityDocumentSignerTest.java @@ -3,13 +3,11 @@ package com.yahoo.vespa.athenz.identityprovider.client; import com.yahoo.security.KeyAlgorithm; import com.yahoo.security.KeyUtils; -import com.yahoo.vespa.athenz.api.AthenzIdentity; import com.yahoo.vespa.athenz.api.AthenzService; import com.yahoo.vespa.athenz.identityprovider.api.ClusterType; import com.yahoo.vespa.athenz.identityprovider.api.IdentityType; import com.yahoo.vespa.athenz.identityprovider.api.SignedIdentityDocument; import com.yahoo.vespa.athenz.identityprovider.api.VespaUniqueInstanceId; -import com.yahoo.vespa.athenz.utils.AthenzIdentities; import org.junit.jupiter.api.Test; import java.security.KeyPair; @@ -38,54 +36,37 @@ public class IdentityDocumentSignerTest { private static final Instant createdAt = Instant.EPOCH; private static final HashSet<String> ipAddresses = new HashSet<>(Arrays.asList("1.2.3.4", "::1")); private static final ClusterType clusterType = ClusterType.CONTAINER; - private static final String ztsUrl = "https://foo"; - private static final AthenzIdentity serviceIdentity = new AthenzService("vespa", "node"); @Test void generates_and_validates_signature() { IdentityDocumentSigner signer = new IdentityDocumentSigner(); String signature = signer.generateSignature(id, providerService, configserverHostname, instanceHostname, createdAt, - ipAddresses, identityType, keyPair.getPrivate(), serviceIdentity); + ipAddresses, identityType, keyPair.getPrivate()); SignedIdentityDocument signedIdentityDocument = new SignedIdentityDocument( signature, KEY_VERSION, id, providerService, DEFAULT_DOCUMENT_VERSION, configserverHostname, - instanceHostname, createdAt, ipAddresses, identityType, clusterType, ztsUrl, serviceIdentity); + instanceHostname, createdAt, ipAddresses, identityType, clusterType); assertTrue(signer.hasValidSignature(signedIdentityDocument, keyPair.getPublic())); } @Test - void ignores_cluster_type_and_zts_url() { + void ignores_cluster_type() { IdentityDocumentSigner signer = new IdentityDocumentSigner(); String signature = signer.generateSignature(id, providerService, configserverHostname, instanceHostname, createdAt, - ipAddresses, identityType, keyPair.getPrivate(), serviceIdentity); + ipAddresses, identityType, keyPair.getPrivate()); - var docWithoutIgnoredFields = new SignedIdentityDocument( + var docWithoutClusterType = new SignedIdentityDocument( signature, KEY_VERSION, id, providerService, DEFAULT_DOCUMENT_VERSION, configserverHostname, - instanceHostname, createdAt, ipAddresses, identityType, null, null, serviceIdentity); - var docWithIgnoredFields = new SignedIdentityDocument( + instanceHostname, createdAt, ipAddresses, identityType, null); + var docWithClusterType = new SignedIdentityDocument( signature, KEY_VERSION, id, providerService, DEFAULT_DOCUMENT_VERSION, configserverHostname, - instanceHostname, createdAt, ipAddresses, identityType, clusterType, ztsUrl, serviceIdentity); - - assertTrue(signer.hasValidSignature(docWithoutIgnoredFields, keyPair.getPublic())); - assertEquals(docWithIgnoredFields.signature(), docWithoutIgnoredFields.signature()); - } - - @Test - void validates_signature_for_new_and_old_versions() { - IdentityDocumentSigner signer = new IdentityDocumentSigner(); - String signature = - signer.generateSignature(id, providerService, configserverHostname, instanceHostname, createdAt, - ipAddresses, identityType, keyPair.getPrivate(), serviceIdentity); - - SignedIdentityDocument signedIdentityDocument = new SignedIdentityDocument( - signature, KEY_VERSION, id, providerService, DEFAULT_DOCUMENT_VERSION, configserverHostname, - instanceHostname, createdAt, ipAddresses, identityType, clusterType, ztsUrl, serviceIdentity); - - assertTrue(signer.hasValidSignature(signedIdentityDocument, keyPair.getPublic())); + instanceHostname, createdAt, ipAddresses, identityType, clusterType); + assertTrue(signer.hasValidSignature(docWithoutClusterType, keyPair.getPublic())); + assertEquals(docWithClusterType.signature(), docWithoutClusterType.signature()); } }
\ No newline at end of file diff --git a/vespa-dependencies-enforcer/allowed-maven-dependencies.txt b/vespa-dependencies-enforcer/allowed-maven-dependencies.txt index a3a488d1e25..d5166a7e22f 100644 --- a/vespa-dependencies-enforcer/allowed-maven-dependencies.txt +++ b/vespa-dependencies-enforcer/allowed-maven-dependencies.txt @@ -28,6 +28,7 @@ com.google.code.findbugs:jsr305:3.0.2 com.google.errorprone:error_prone_annotations:2.18.0 com.google.guava:failureaccess:1.0.1 com.google.guava:guava:27.1-jre +com.google.inject:guice:4.2.3 com.google.inject:guice:4.2.3:no_aop com.google.j2objc:j2objc-annotations:1.1 com.google.protobuf:protobuf-java:3.21.7 @@ -220,7 +221,6 @@ xml-apis:xml-apis:1.4.01 com.github.luben:zstd-jni:1.5.2-1 com.github.tomakehurst:wiremock-jre8-standalone:2.35.0 com.google.guava:guava-testlib:27.1-jre -com.google.inject:guice:4.2.3 com.google.jimfs:jimfs:1.2 junit:junit:4.13.2 net.bytebuddy:byte-buddy:1.11.19 |