aboutsummaryrefslogtreecommitdiffstats
path: root/node-repository
diff options
context:
space:
mode:
authorBjørn Christian Seime <bjorn.christian@seime.no>2018-04-19 13:39:51 +0200
committerGitHub <noreply@github.com>2018-04-19 13:39:51 +0200
commit63d72365aa07444aeecea1f6c50afb67914bef5c (patch)
treeec295676d5a42ebb8aab511a221f04e622599d72 /node-repository
parentbd08540974dc5182c8f634c3160cf85a8ed6ae04 (diff)
parent730cb6dc10b4012002a6c5c49f140c33a55ba1ea (diff)
Merge pull request #5630 from vespa-engine/bjorncs/configserver-authz-filter
Bjorncs/configserver authz filter
Diffstat (limited to 'node-repository')
-rw-r--r--node-repository/src/main/java/com/yahoo/vespa/hosted/provision/restapi/v2/filter/AuthorizationFilter.java54
-rw-r--r--node-repository/src/main/java/com/yahoo/vespa/hosted/provision/restapi/v2/filter/HostAuthenticator.java112
-rw-r--r--node-repository/src/main/java/com/yahoo/vespa/hosted/provision/restapi/v2/filter/NodePrincipal.java33
-rw-r--r--node-repository/src/main/java/com/yahoo/vespa/hosted/provision/restapi/v2/filter/package-info.java3
-rw-r--r--node-repository/src/test/java/com/yahoo/vespa/hosted/provision/restapi/v2/filter/HostAuthenticatorTest.java143
5 files changed, 322 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
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..e301d8a80f9
--- /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());
+ NodePrincipal 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());
+ NodePrincipal 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());
+ NodePrincipal 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