diff options
author | Bjørn Christian Seime <bjorncs@oath.com> | 2018-04-19 12:19:00 +0200 |
---|---|---|
committer | Bjørn Christian Seime <bjorncs@oath.com> | 2018-04-19 12:19:00 +0200 |
commit | a84e851515fe4781cc8848f5023ab6edad4814c6 (patch) | |
tree | ad9e6acde8ee4066ffb7332530d0a93a7f6a1b82 /node-repository | |
parent | 142f82353c20d64cc00fb5c58a3e1cf874ee6678 (diff) |
Support Athenz certs from Openstack and Vespa in authorization filter
- Add HostAuthenticator that resolves node (hostname) from certs
- Add TlsPrincipal that represents an authenticated node
- Rewrite Authorization filter to use HostAuthenticator
Diffstat (limited to 'node-repository')
4 files changed, 321 insertions, 23 deletions
diff --git a/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/restapi/v2/filter/AuthorizationFilter.java b/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/restapi/v2/filter/AuthorizationFilter.java index 737308d004d..360a6a1aa73 100644 --- a/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/restapi/v2/filter/AuthorizationFilter.java +++ b/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/restapi/v2/filter/AuthorizationFilter.java @@ -8,7 +8,6 @@ import com.yahoo.jdisc.handler.ResponseHandler; import com.yahoo.jdisc.http.filter.DiscFilterRequest; import com.yahoo.jdisc.http.filter.SecurityRequestFilter; import com.yahoo.net.HostName; -import com.yahoo.vespa.athenz.tls.X509CertificateUtils; import com.yahoo.vespa.hosted.provision.NodeRepository; import com.yahoo.vespa.hosted.provision.restapi.v2.Authorizer; import com.yahoo.vespa.hosted.provision.restapi.v2.ErrorResponse; @@ -16,6 +15,7 @@ import com.yahoo.vespa.hosted.provision.restapi.v2.ErrorResponse; import java.net.URI; import java.security.Principal; import java.security.cert.X509Certificate; +import java.util.List; import java.util.Optional; import java.util.function.BiConsumer; import java.util.function.BiPredicate; @@ -34,6 +34,7 @@ public class AuthorizationFilter implements SecurityRequestFilter { private final BiPredicate<Principal, URI> authorizer; private final BiConsumer<ErrorResponse, ResponseHandler> rejectAction; + private final HostAuthenticator hostAuthenticator; @Inject public AuthorizationFilter(Zone zone, NodeRepository nodeRepository, NodeRepositoryConfig nodeRepositoryConfig) { @@ -45,44 +46,51 @@ public class AuthorizationFilter implements SecurityRequestFilter { Stream.of(HostName.getLocalhost()), Stream.of(nodeRepositoryConfig.hostnameWhitelist().split(",")) ).filter(hostname -> !hostname.isEmpty()).collect(Collectors.toSet())), - AuthorizationFilter::logAndReject + AuthorizationFilter::logAndReject, + new HostAuthenticator(zone, nodeRepository) ); } AuthorizationFilter(BiPredicate<Principal, URI> authorizer, - BiConsumer<ErrorResponse, ResponseHandler> rejectAction) { + BiConsumer<ErrorResponse, ResponseHandler> rejectAction, + HostAuthenticator hostAuthenticator) { this.authorizer = authorizer; this.rejectAction = rejectAction; + this.hostAuthenticator = hostAuthenticator; } @Override public void filter(DiscFilterRequest request, ResponseHandler handler) { - Optional<String> commonName = request.getClientCertificateChain().stream() - .findFirst() - .flatMap(AuthorizationFilter::commonName); - if (commonName.isPresent()) { - if (!authorizer.test(commonName::get, request.getUri())) { - rejectAction.accept(ErrorResponse.forbidden( - String.format("%s %s denied for %s: Invalid credentials", request.getMethod(), - request.getUri().getPath(), request.getRemoteAddr())), handler - ); - } - } else { - rejectAction.accept(ErrorResponse.unauthorized( - String.format("%s %s denied for %s: Missing credentials", request.getMethod(), - request.getUri().getPath(), request.getRemoteAddr())), handler - ); + validateAccess(request) + .ifPresent(errorResponse -> rejectAction.accept(errorResponse, handler)); + } + + private Optional<ErrorResponse> validateAccess(DiscFilterRequest request) { + try { + List<X509Certificate> clientCertificateChain = request.getClientCertificateChain(); + if (clientCertificateChain.isEmpty()) + return Optional.of(ErrorResponse.unauthorized(createErrorMessage(request, "Missing credentials"))); + TlsPrincipal hostIdentity = hostAuthenticator.authenticate(clientCertificateChain); + if (!authorizer.test(hostIdentity, request.getUri())) + return Optional.of(ErrorResponse.forbidden(createErrorMessage(request, "Invalid credentials"))); + request.setUserPrincipal(hostIdentity); + return Optional.empty(); + } catch (HostAuthenticator.AuthenticationException e) { + return Optional.of(ErrorResponse.forbidden(createErrorMessage(request, "Invalid credentials: " + e.getMessage()))); } } + private static String createErrorMessage(DiscFilterRequest request, String message) { + return String.format("%s %s denied for %s: %s", + request.getMethod(), + request.getUri().getPath(), + request.getRemoteAddr(), + message); + } + private static void logAndReject(ErrorResponse response, ResponseHandler handler) { log.warning(response.message()); FilterUtils.write(response, handler); } - /** Read common name (CN) from certificate */ - private static Optional<String> commonName(X509Certificate certificate) { - return X509CertificateUtils.getSubjectCommonNames(certificate).stream().findFirst(); - } - } diff --git a/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/restapi/v2/filter/HostAuthenticator.java b/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/restapi/v2/filter/HostAuthenticator.java new file mode 100644 index 00000000000..80599a9c990 --- /dev/null +++ b/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/restapi/v2/filter/HostAuthenticator.java @@ -0,0 +1,112 @@ +// Copyright 2018 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.hosted.provision.restapi.v2.filter; + +import com.yahoo.config.provision.ApplicationId; +import com.yahoo.config.provision.Zone; +import com.yahoo.vespa.athenz.identityprovider.api.VespaUniqueInstanceId; +import com.yahoo.vespa.athenz.tls.SubjectAlternativeName; +import com.yahoo.vespa.athenz.tls.X509CertificateUtils; +import com.yahoo.vespa.hosted.provision.Node; +import com.yahoo.vespa.hosted.provision.NodeRepository; + +import java.security.cert.X509Certificate; +import java.util.List; + +import static com.yahoo.vespa.athenz.tls.SubjectAlternativeName.Type.DNS_NAME; + +/** + * Resolve node from various types of x509 identity certificates. + * + * @author bjorncs + */ +class HostAuthenticator { + + private static final String TENANT_DOCKER_HOST_IDENTITY = "vespa.vespa.tenant-host"; + private static final String TENANT_DOCKER_CONTAINER_IDENTITY = "vespa.vespa.tenant"; + private static final String INSTANCE_ID_DELIMITER = ".instanceid.athenz."; + + private final Zone zone; + private final NodeRepository nodeRepository; + + HostAuthenticator(Zone zone, NodeRepository nodeRepository) { + this.zone = zone; + this.nodeRepository = nodeRepository; + } + + TlsPrincipal authenticate(List<X509Certificate> certificateChain) throws AuthenticationException { + X509Certificate clientCertificate = certificateChain.get(0); + String subjectCommonName = X509CertificateUtils.getSubjectCommonNames(clientCertificate).stream() + .findFirst() + .orElseThrow(() -> new AuthenticationException("Certificate subject common name is missing!")); + if (isAthenzIssued(clientCertificate)) { + String hostname; + List<SubjectAlternativeName> sans = X509CertificateUtils.getSubjectAlternativeNames(clientCertificate); + switch (subjectCommonName) { + case TENANT_DOCKER_HOST_IDENTITY: + hostname = getHostFromCalypsoCertificate(sans); + break; + case TENANT_DOCKER_CONTAINER_IDENTITY: + hostname = getHostFromVespaCertificate(sans); + break; + default: + throw new AuthenticationException("Untrusted common name in subject: " + subjectCommonName); + } + return new TlsPrincipal(hostname, certificateChain); + } else { // self-signed where common name is hostname + // TODO Remove this branch once self-signed certificates are gone + return new TlsPrincipal(subjectCommonName, certificateChain); + } + } + + private boolean isAthenzIssued(X509Certificate certificate) { + String issuerCommonName = X509CertificateUtils.getIssuerCommonNames(certificate).stream() + .findFirst() + .orElseThrow(() -> new AuthenticationException("Certificate issuer common name is missing!")); + return issuerCommonName.equals("Yahoo Athenz CA"); + } + + private String getHostFromCalypsoCertificate(List<SubjectAlternativeName> sans) { + String openstackId = getUniqueInstanceId(sans); + return nodeRepository.getNodes().stream() + .filter(node -> node.openStackId().equals(openstackId)) + .map(Node::hostname) + .findFirst() + .orElseThrow(() -> new AuthenticationException(String.format("Cannot find node with openstack-id '%s' in node repository", openstackId))); + } + + private String getHostFromVespaCertificate(List<SubjectAlternativeName> sans) { + VespaUniqueInstanceId instanceId = VespaUniqueInstanceId.fromDottedString(getUniqueInstanceId(sans)); + if (!zone.environment().value().equals(instanceId.environment())) + throw new AuthenticationException("Invalid environment: " + instanceId.environment()); + if (!zone.region().value().equals(instanceId.region())) + throw new AuthenticationException("Invalid region(): " + instanceId.region()); + List<Node> applicationNodes = + nodeRepository.getNodes(ApplicationId.from(instanceId.tenant(), instanceId.application(), instanceId.instance())); + return applicationNodes.stream() + .filter( + node -> node.allocation() + .map(allocation -> allocation.membership().index() == instanceId.clusterIndex() + && allocation.membership().cluster().id().value().equals(instanceId.clusterId())) + .orElse(false)) + .map(Node::hostname) + .findFirst() + .orElseThrow(() -> new AuthenticationException("Could not find any node with instance id: " + instanceId.asDottedString())); + } + + private static String getUniqueInstanceId(List<SubjectAlternativeName> sans) { + return sans.stream() + .filter(san -> san.getType() == DNS_NAME) + .map(SubjectAlternativeName::getValue) + .filter(dnsName -> (dnsName.endsWith("yahoo.cloud") || dnsName.endsWith("oath.cloud")) && dnsName.contains(INSTANCE_ID_DELIMITER)) + .map(dnsName -> dnsName.substring(0, dnsName.indexOf(INSTANCE_ID_DELIMITER))) + .findFirst() + .orElseThrow(() -> new AuthenticationException("Could not find unique instance id from SAN addresses: " + sans)); + } + + static class AuthenticationException extends RuntimeException { + AuthenticationException(String message) { + super(message); + } + } + +} diff --git a/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/restapi/v2/filter/TlsPrincipal.java b/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/restapi/v2/filter/TlsPrincipal.java new file mode 100644 index 00000000000..227c514160b --- /dev/null +++ b/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/restapi/v2/filter/TlsPrincipal.java @@ -0,0 +1,35 @@ +// Copyright 2018 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.hosted.provision.restapi.v2.filter; + +import com.yahoo.vespa.athenz.tls.SubjectAlternativeName; + +import java.security.Principal; +import java.security.cert.X509Certificate; +import java.util.List; + +/** + * @author bjorncs + */ +public class TlsPrincipal implements Principal { + private final String hostIdentity; + private final List<X509Certificate> clientCertificateChain; + + public TlsPrincipal(String hostIdentity, List<X509Certificate> clientCertificateChain) { + this.hostIdentity = hostIdentity; + this.clientCertificateChain = clientCertificateChain; + } + + public String getHostIdentityName() { + return hostIdentity; + } + + public List<X509Certificate> getClientCertificateChain() { + return clientCertificateChain; + } + + @Override + public String getName() { + return hostIdentity; + } + +} diff --git a/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/restapi/v2/filter/HostAuthenticatorTest.java b/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/restapi/v2/filter/HostAuthenticatorTest.java new file mode 100644 index 00000000000..fa6c25c189b --- /dev/null +++ b/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/restapi/v2/filter/HostAuthenticatorTest.java @@ -0,0 +1,143 @@ +package com.yahoo.vespa.hosted.provision.restapi.v2.filter; + +import com.yahoo.component.Version; +import com.yahoo.config.provision.ApplicationId; +import com.yahoo.config.provision.ClusterMembership; +import com.yahoo.config.provision.ClusterSpec; +import com.yahoo.config.provision.Environment; +import com.yahoo.config.provision.Flavor; +import com.yahoo.config.provision.NodeType; +import com.yahoo.config.provision.RegionName; +import com.yahoo.config.provision.SystemName; +import com.yahoo.config.provision.Zone; +import com.yahoo.config.provisioning.FlavorsConfig; +import com.yahoo.vespa.athenz.identityprovider.api.VespaUniqueInstanceId; +import com.yahoo.vespa.athenz.tls.KeyUtils; +import com.yahoo.vespa.athenz.tls.Pkcs10Csr; +import com.yahoo.vespa.athenz.tls.Pkcs10CsrBuilder; +import com.yahoo.vespa.athenz.tls.X509CertificateBuilder; +import com.yahoo.vespa.hosted.provision.Node; +import com.yahoo.vespa.hosted.provision.NodeRepositoryTester; +import com.yahoo.vespa.hosted.provision.node.Allocation; +import com.yahoo.vespa.hosted.provision.node.Generation; +import com.yahoo.vespa.hosted.provision.provisioning.FlavorConfigBuilder; +import org.junit.Test; + +import javax.security.auth.x500.X500Principal; +import java.security.KeyPair; +import java.security.cert.X509Certificate; +import java.time.Instant; +import java.util.Optional; + +import static com.yahoo.vespa.athenz.tls.KeyAlgorithm.RSA; +import static com.yahoo.vespa.athenz.tls.SignatureAlgorithm.SHA256_WITH_RSA; +import static java.util.Collections.emptySet; +import static java.util.Collections.singleton; +import static java.util.Collections.singletonList; +import static org.junit.Assert.assertEquals; + +/** + * @author bjorncs + */ +public class HostAuthenticatorTest { + private static final String HOSTNAME = "myhostname"; + private static final String OPENSTACK_ID = "OPENSTACK-ID"; + private static final String INSTANCE_ID = "default"; + private static final Zone ZONE = new Zone(SystemName.main, Environment.prod, RegionName.defaultName()); + private static final KeyPair KEYPAIR = KeyUtils.generateKeypair(RSA); + private static final X509Certificate ATHENZ_CA_DUMMY = createAthenzCaDummyCertificate(); + + @Test + public void accepts_configserver_selfsigned_cert() { + NodeRepositoryTester nodeRepositoryDummy = new NodeRepositoryTester(); + X509Certificate certificate = X509CertificateBuilder + .fromKeypair( + KEYPAIR, new X500Principal("CN=" + HOSTNAME), Instant.EPOCH, Instant.EPOCH.plusSeconds(60), SHA256_WITH_RSA, 1) + .build(); + HostAuthenticator authenticator = new HostAuthenticator(ZONE, nodeRepositoryDummy.nodeRepository()); + TlsPrincipal identity = authenticator.authenticate(singletonList(certificate)); + assertEquals(HOSTNAME, identity.getName()); + } + + @Test + public void accepts_openstack_host_certificate() { + NodeRepositoryTester nodeRepositoryDummy = new NodeRepositoryTester(); + nodeRepositoryDummy.addNode(OPENSTACK_ID, HOSTNAME, INSTANCE_ID, NodeType.host); + nodeRepositoryDummy.setNodeState(HOSTNAME, Node.State.active); + Pkcs10Csr csr = Pkcs10CsrBuilder + .fromKeypair(new X500Principal("CN=vespa.vespa.tenant-host"), KEYPAIR, SHA256_WITH_RSA) + .build(); + X509Certificate certificate = X509CertificateBuilder + .fromCsr(csr, ATHENZ_CA_DUMMY.getSubjectX500Principal(), Instant.EPOCH, Instant.EPOCH.plusSeconds(60), KEYPAIR.getPrivate(), SHA256_WITH_RSA, 1) + .addSubjectAlternativeName(OPENSTACK_ID + ".instanceid.athenz.provider-name.ostk.yahoo.cloud") + .build(); + HostAuthenticator authenticator = new HostAuthenticator(ZONE, nodeRepositoryDummy.nodeRepository()); + TlsPrincipal identity = authenticator.authenticate(singletonList(certificate)); + assertEquals(HOSTNAME, identity.getName()); + } + + @Test + public void accepts_docker_container_certificate() { + String clusterId = "clusterid"; + int clusterIndex = 0; + String tenant = "tenant"; + String application = "application"; + String region = ZONE.region().value(); + String environment = ZONE.environment().value(); + NodeRepositoryTester nodeRepositoryDummy = new NodeRepositoryTester(); + Node node = createNode(clusterId, clusterIndex, tenant, application); + nodeRepositoryDummy.nodeRepository().addDockerNodes(singletonList(node)); + Pkcs10Csr csr = Pkcs10CsrBuilder + .fromKeypair(new X500Principal("CN=vespa.vespa.tenant"), KEYPAIR, SHA256_WITH_RSA) + .build(); + VespaUniqueInstanceId vespaUniqueInstanceId = new VespaUniqueInstanceId(clusterIndex, clusterId, INSTANCE_ID, application, tenant, region, environment); + X509Certificate certificate = X509CertificateBuilder + .fromCsr(csr, ATHENZ_CA_DUMMY.getSubjectX500Principal(), Instant.EPOCH, Instant.EPOCH.plusSeconds(60), KEYPAIR.getPrivate(), SHA256_WITH_RSA, 1) + .addSubjectAlternativeName(vespaUniqueInstanceId.asDottedString() + ".instanceid.athenz.provider-name.vespa.yahoo.cloud") + .build(); + HostAuthenticator authenticator = new HostAuthenticator(ZONE, nodeRepositoryDummy.nodeRepository()); + TlsPrincipal identity = authenticator.authenticate(singletonList(certificate)); + assertEquals(HOSTNAME, identity.getName()); + } + + private static Node createNode(String clusterId, int clusterIndex, String tenant, String application) { + return Node + .createDockerNode( + OPENSTACK_ID, + singleton("1.2.3.4"), + emptySet(), + HOSTNAME, + Optional.of("parenthost"), + new Flavor(createFlavourConfig().flavor(0)), + NodeType.tenant) + .with( + new Allocation( + ApplicationId.from(tenant, application, INSTANCE_ID), + ClusterMembership.from( + ClusterSpec.from( + ClusterSpec.Type.container, + new ClusterSpec.Id(clusterId), + ClusterSpec.Group.from(0), + Version.emptyVersion), + clusterIndex), + Generation.inital(), + false)); + + } + + private static X509Certificate createAthenzCaDummyCertificate() { + KeyPair keyPair = KeyUtils.generateKeypair(RSA); + return X509CertificateBuilder + .fromKeypair( + keyPair, new X500Principal("CN=Yahoo Athenz CA"), Instant.EPOCH, Instant.EPOCH.plusSeconds(60), SHA256_WITH_RSA, 1) + .setBasicConstraints(true, true) + .build(); + + } + + private static FlavorsConfig createFlavourConfig() { + FlavorConfigBuilder b = new FlavorConfigBuilder(); + b.addFlavor("docker", 1., 2., 50, Flavor.Type.DOCKER_CONTAINER).cost(1); + return b.build(); + } +}
\ No newline at end of file |