diff options
Diffstat (limited to 'node-repository/src/main/java/com')
4 files changed, 179 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..ccc09aad24a 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"))); + NodePrincipal 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..de8d117de11 --- /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; + } + + NodePrincipal 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 NodePrincipal(hostname, certificateChain); + } else { // self-signed where common name is hostname + // TODO Remove this branch once self-signed certificates are gone + return new NodePrincipal(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") || issuerCommonName.equals("Athenz AWS 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/NodePrincipal.java b/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/restapi/v2/filter/NodePrincipal.java new file mode 100644 index 00000000000..dbff2b0da34 --- /dev/null +++ b/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/restapi/v2/filter/NodePrincipal.java @@ -0,0 +1,33 @@ +// 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 java.security.Principal; +import java.security.cert.X509Certificate; +import java.util.List; + +/** + * @author bjorncs + */ +public class NodePrincipal implements Principal { + private final String hostIdentity; + private final List<X509Certificate> clientCertificateChain; + + public NodePrincipal(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/main/java/com/yahoo/vespa/hosted/provision/restapi/v2/filter/package-info.java b/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/restapi/v2/filter/package-info.java index 0e68629ef5e..e2a40f398a9 100644 --- a/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/restapi/v2/filter/package-info.java +++ b/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/restapi/v2/filter/package-info.java @@ -2,4 +2,7 @@ /** * @author mpolden */ +@ExportPackage package com.yahoo.vespa.hosted.provision.restapi.v2.filter; + +import com.yahoo.osgi.annotation.ExportPackage;
\ No newline at end of file |