summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--config-model-api/src/main/java/com/yahoo/config/model/api/ModelContext.java1
-rw-r--r--config-model/src/main/java/com/yahoo/config/model/deploy/TestProperties.java7
-rwxr-xr-xconfig-model/src/main/java/com/yahoo/vespa/model/container/ContainerCluster.java14
-rw-r--r--config-model/src/main/java/com/yahoo/vespa/model/container/component/SystemBindingPattern.java1
-rw-r--r--config-model/src/main/java/com/yahoo/vespa/model/container/http/Client.java34
-rw-r--r--config-model/src/main/java/com/yahoo/vespa/model/container/xml/CloudDataPlaneFilter.java53
-rw-r--r--config-model/src/main/java/com/yahoo/vespa/model/container/xml/ContainerModelBuilder.java119
-rw-r--r--config-model/src/main/resources/schema/containercluster.rnc15
-rw-r--r--config-model/src/test/java/com/yahoo/vespa/model/container/xml/CloudDataPlaneFilterTest.java189
-rw-r--r--config-model/src/test/schema-test-files/services.xml10
-rw-r--r--configdefinitions/src/main/java/com/yahoo/jdisc/http/filter/security/cloud/config/package-info.java6
-rw-r--r--configdefinitions/src/vespa/cloud-data-plane-filter.def7
-rw-r--r--configserver/src/main/java/com/yahoo/vespa/config/server/deploy/ModelContextImpl.java3
-rw-r--r--flags/src/main/java/com/yahoo/vespa/flags/Flags.java7
-rw-r--r--jdisc-security-filters/src/main/java/com/yahoo/jdisc/http/filter/security/cloud/CloudDataPlaneFilter.java146
-rw-r--r--jdisc-security-filters/src/main/java/com/yahoo/jdisc/http/filter/security/cloud/package-info.java8
-rw-r--r--jdisc-security-filters/src/test/java/com/yahoo/jdisc/http/filter/security/cloud/CloudDataPlaneFilterTest.java151
17 files changed, 763 insertions, 8 deletions
diff --git a/config-model-api/src/main/java/com/yahoo/config/model/api/ModelContext.java b/config-model-api/src/main/java/com/yahoo/config/model/api/ModelContext.java
index 918e91ed0ce..a4e62dc5488 100644
--- a/config-model-api/src/main/java/com/yahoo/config/model/api/ModelContext.java
+++ b/config-model-api/src/main/java/com/yahoo/config/model/api/ModelContext.java
@@ -111,6 +111,7 @@ public interface ModelContext {
@ModelFeatureFlag(owners = {"vekterli"}) default boolean useTwoPhaseDocumentGc() { return false; }
@ModelFeatureFlag(owners = {"tokle"}) default boolean useRestrictedDataPlaneBindings() { return false; }
@ModelFeatureFlag(owners = {"arnej","baldersheim"}) default boolean useOldJdiscContainerStartup() { return true; }
+ @ModelFeatureFlag(owners = {"tokle, bjorncs"}) default boolean enableDataPlaneFilter() { return false; }
//Below are all flags that must be kept until 7 is out of the door
@ModelFeatureFlag(owners = {"vekterli"}, removeAfter="7.last") default boolean useThreePhaseUpdates() { return true; }
diff --git a/config-model/src/main/java/com/yahoo/config/model/deploy/TestProperties.java b/config-model/src/main/java/com/yahoo/config/model/deploy/TestProperties.java
index 6b8428a07ac..328f1b19f10 100644
--- a/config-model/src/main/java/com/yahoo/config/model/deploy/TestProperties.java
+++ b/config-model/src/main/java/com/yahoo/config/model/deploy/TestProperties.java
@@ -82,6 +82,7 @@ public class TestProperties implements ModelContext.Properties, ModelContext.Fea
private Architecture adminClusterNodeResourcesArchitecture = Architecture.getDefault();
private boolean useRestrictedDataPlaneBindings = false;
private Optional<CloudAccount> cloudAccount = Optional.empty();
+ private boolean enableDataPlaneFilter = false;
@Override public ModelContext.FeatureFlags featureFlags() { return this; }
@Override public boolean multitenant() { return multitenant; }
@@ -137,6 +138,7 @@ public class TestProperties implements ModelContext.Properties, ModelContext.Fea
@Override public boolean useTwoPhaseDocumentGc() { return useTwoPhaseDocumentGc; }
@Override public boolean useRestrictedDataPlaneBindings() { return useRestrictedDataPlaneBindings; }
@Override public Optional<CloudAccount> cloudAccount() { return cloudAccount; }
+ @Override public boolean enableDataPlaneFilter() { return enableDataPlaneFilter; }
public TestProperties sharedStringRepoNoReclaim(boolean sharedStringRepoNoReclaim) {
this.sharedStringRepoNoReclaim = sharedStringRepoNoReclaim;
@@ -366,6 +368,11 @@ public class TestProperties implements ModelContext.Properties, ModelContext.Fea
return this;
}
+ public TestProperties setEnableDataPlaneFilter(boolean enableDataPlaneFilter) {
+ this.enableDataPlaneFilter = enableDataPlaneFilter;
+ return this;
+ }
+
public static class Spec implements ConfigServerSpec {
private final String hostName;
diff --git a/config-model/src/main/java/com/yahoo/vespa/model/container/ContainerCluster.java b/config-model/src/main/java/com/yahoo/vespa/model/container/ContainerCluster.java
index f5f25c65cd4..f1b3c74a55d 100755
--- a/config-model/src/main/java/com/yahoo/vespa/model/container/ContainerCluster.java
+++ b/config-model/src/main/java/com/yahoo/vespa/model/container/ContainerCluster.java
@@ -52,6 +52,7 @@ import com.yahoo.vespa.model.container.component.chain.ProcessingHandler;
import com.yahoo.vespa.model.container.configserver.ConfigserverCluster;
import com.yahoo.vespa.model.container.docproc.ContainerDocproc;
import com.yahoo.vespa.model.container.docproc.DocprocChains;
+import com.yahoo.vespa.model.container.http.Client;
import com.yahoo.vespa.model.container.http.Http;
import com.yahoo.vespa.model.container.processing.ProcessingChains;
import com.yahoo.vespa.model.container.search.ContainerSearch;
@@ -161,6 +162,8 @@ public abstract class ContainerCluster<CONTAINER extends Container>
private String jvmGCOptions = null;
private boolean deferChangesUntilRestart = false;
+ private boolean clientsLegacyMode;
+ private List<Client> clients = List.of();
public ContainerCluster(AbstractConfigProducer<?> parent, String configSubId, String clusterId, DeployState deployState, boolean zooKeeperLocalhostAffinity) {
this(parent, configSubId, clusterId, deployState, zooKeeperLocalhostAffinity, 1);
@@ -352,6 +355,17 @@ public abstract class ContainerCluster<CONTAINER extends Container>
return http;
}
+ public void setClients(boolean legacyMode, List<Client> clients) {
+ clientsLegacyMode = legacyMode;
+ this.clients = clients;
+ }
+
+ public List<Client> getClients() {
+ return clients;
+ }
+
+ public boolean clientsLegacyMode() { return clientsLegacyMode; }
+
public ContainerDocproc getDocproc() {
return containerDocproc;
}
diff --git a/config-model/src/main/java/com/yahoo/vespa/model/container/component/SystemBindingPattern.java b/config-model/src/main/java/com/yahoo/vespa/model/container/component/SystemBindingPattern.java
index 201e26d3575..606557670a5 100644
--- a/config-model/src/main/java/com/yahoo/vespa/model/container/component/SystemBindingPattern.java
+++ b/config-model/src/main/java/com/yahoo/vespa/model/container/component/SystemBindingPattern.java
@@ -14,6 +14,7 @@ public class SystemBindingPattern extends BindingPattern {
public static SystemBindingPattern fromHttpPath(String path) { return new SystemBindingPattern("http", "*", null, path);}
public static SystemBindingPattern fromPattern(String binding) { return new SystemBindingPattern(binding);}
public static SystemBindingPattern fromHttpPortAndPath(String port, String path) { return new SystemBindingPattern("http", "*", port, path); }
+ public static SystemBindingPattern fromHttpPortAndPath(int port, String path) { return new SystemBindingPattern("http", "*", Integer.toString(port), path); }
@Override
public String toString() {
diff --git a/config-model/src/main/java/com/yahoo/vespa/model/container/http/Client.java b/config-model/src/main/java/com/yahoo/vespa/model/container/http/Client.java
new file mode 100644
index 00000000000..c851ab2bee6
--- /dev/null
+++ b/config-model/src/main/java/com/yahoo/vespa/model/container/http/Client.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.model.container.http;
+
+import java.security.cert.X509Certificate;
+import java.util.List;
+
+/**
+ * Represents a client. The client is identified by one of the provided certificates and have a set of permissions.
+ *
+ * @author mortent
+ */
+public class Client {
+ private String id;
+ private List<String> permissions;
+ private List<X509Certificate> certificates;
+
+ public Client(String id, List<String> permissions, List<X509Certificate> certificates) {
+ this.id = id;
+ this.permissions = permissions;
+ this.certificates = certificates;
+ }
+
+ public String id() {
+ return id;
+ }
+
+ public List<String> permissions() {
+ return permissions;
+ }
+
+ public List<X509Certificate> certificates() {
+ return certificates;
+ }
+}
diff --git a/config-model/src/main/java/com/yahoo/vespa/model/container/xml/CloudDataPlaneFilter.java b/config-model/src/main/java/com/yahoo/vespa/model/container/xml/CloudDataPlaneFilter.java
new file mode 100644
index 00000000000..6767a61d02b
--- /dev/null
+++ b/config-model/src/main/java/com/yahoo/vespa/model/container/xml/CloudDataPlaneFilter.java
@@ -0,0 +1,53 @@
+// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.vespa.model.container.xml;
+
+import com.yahoo.component.ComponentSpecification;
+import com.yahoo.component.chain.dependencies.Dependencies;
+import com.yahoo.component.chain.model.ChainedComponentModel;
+import com.yahoo.container.bundle.BundleInstantiationSpecification;
+import com.yahoo.jdisc.http.filter.security.cloud.config.CloudDataPlaneFilterConfig;
+import com.yahoo.security.X509CertificateUtils;
+import com.yahoo.vespa.model.container.ApplicationContainerCluster;
+import com.yahoo.vespa.model.container.http.Client;
+import com.yahoo.vespa.model.container.http.Filter;
+
+import java.util.List;
+
+class CloudDataPlaneFilter extends Filter implements CloudDataPlaneFilterConfig.Producer {
+
+ private static final String CLASS = "com.yahoo.jdisc.http.filter.security.cloud.CloudDataPlaneFilter";
+ private static final String BUNDLE = "jdisc-security-filters";
+
+ private final ApplicationContainerCluster cluster;
+ private final boolean legacyMode;
+
+ CloudDataPlaneFilter(ApplicationContainerCluster cluster, boolean legacyMode) {
+ super(model());
+ this.cluster = cluster;
+ this.legacyMode = legacyMode;
+ }
+
+ private static ChainedComponentModel model() {
+ return new ChainedComponentModel(
+ new BundleInstantiationSpecification(
+ new ComponentSpecification(CLASS), null, new ComponentSpecification(BUNDLE)),
+ Dependencies.emptyDependencies());
+ }
+
+ @Override
+ public void getConfig(CloudDataPlaneFilterConfig.Builder builder) {
+ if (legacyMode) {
+ builder.legacyMode(true);
+ } else {
+ List<Client> clients = cluster.getClients();
+ builder.legacyMode(false);
+ List<CloudDataPlaneFilterConfig.Clients.Builder> clientsList = clients.stream()
+ .map(x -> new CloudDataPlaneFilterConfig.Clients.Builder()
+ .id(x.id())
+ .certificates(X509CertificateUtils.toPem(x.certificates()))
+ .permissions(x.permissions()))
+ .toList();
+ builder.clients(clientsList);
+ }
+ }
+}
diff --git a/config-model/src/main/java/com/yahoo/vespa/model/container/xml/ContainerModelBuilder.java b/config-model/src/main/java/com/yahoo/vespa/model/container/xml/ContainerModelBuilder.java
index 03c9335bbc4..798ca55bdfa 100644
--- a/config-model/src/main/java/com/yahoo/vespa/model/container/xml/ContainerModelBuilder.java
+++ b/config-model/src/main/java/com/yahoo/vespa/model/container/xml/ContainerModelBuilder.java
@@ -2,8 +2,13 @@
package com.yahoo.vespa.model.container.xml;
import com.google.common.collect.ImmutableList;
+import com.yahoo.component.ComponentId;
+import com.yahoo.component.ComponentSpecification;
import com.yahoo.component.Version;
+import com.yahoo.component.chain.dependencies.Dependencies;
+import com.yahoo.component.chain.model.ChainedComponentModel;
import com.yahoo.config.application.Xml;
+import com.yahoo.config.application.api.ApplicationFile;
import com.yahoo.config.application.api.ApplicationPackage;
import com.yahoo.config.application.api.DeployLogger;
import com.yahoo.config.application.api.DeploymentInstanceSpec;
@@ -31,8 +36,10 @@ import com.yahoo.config.provision.LoadBalancerSettings;
import com.yahoo.config.provision.NodeResources;
import com.yahoo.config.provision.NodeType;
import com.yahoo.config.provision.Zone;
+import com.yahoo.container.bundle.BundleInstantiationSpecification;
import com.yahoo.container.logging.FileConnectionLog;
import com.yahoo.osgi.provider.model.ComponentModel;
+import com.yahoo.path.Path;
import com.yahoo.schema.OnnxModel;
import com.yahoo.schema.derived.RankProfileList;
import com.yahoo.search.rendering.RendererRegistry;
@@ -70,10 +77,14 @@ import com.yahoo.vespa.model.container.component.Handler;
import com.yahoo.vespa.model.container.component.SimpleComponent;
import com.yahoo.vespa.model.container.component.SystemBindingPattern;
import com.yahoo.vespa.model.container.component.UserBindingPattern;
+import com.yahoo.vespa.model.container.component.chain.Chain;
import com.yahoo.vespa.model.container.docproc.ContainerDocproc;
import com.yahoo.vespa.model.container.docproc.DocprocChains;
import com.yahoo.vespa.model.container.http.AccessControl;
+import com.yahoo.vespa.model.container.http.Client;
import com.yahoo.vespa.model.container.http.ConnectorFactory;
+import com.yahoo.vespa.model.container.http.Filter;
+import com.yahoo.vespa.model.container.http.FilterBinding;
import com.yahoo.vespa.model.container.http.FilterChains;
import com.yahoo.vespa.model.container.http.Http;
import com.yahoo.vespa.model.container.http.JettyHttpServer;
@@ -88,7 +99,10 @@ import com.yahoo.vespa.model.content.StorageGroup;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
+import java.io.IOException;
+import java.io.InputStream;
import java.net.URI;
+import java.nio.charset.StandardCharsets;
import java.security.cert.X509Certificate;
import java.util.ArrayList;
import java.util.Arrays;
@@ -106,6 +120,7 @@ import java.util.logging.Level;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
+import static com.yahoo.vespa.model.container.ContainerCluster.VIP_HANDLER_BINDING;
import static java.util.logging.Level.WARNING;
/**
@@ -202,6 +217,7 @@ public class ContainerModelBuilder extends ConfigModelBuilder<ContainerModel> {
addStatusHandlers(cluster, context.getDeployState().isHosted());
addUserHandlers(deployState, cluster, spec, context);
+ addClients(deployState, spec, cluster, context);
addHttp(deployState, spec, cluster, context);
addAccessLogs(deployState, cluster, spec);
@@ -439,6 +455,86 @@ public class ContainerModelBuilder extends ConfigModelBuilder<ContainerModel> {
addHostedImplicitAccessControlIfNotPresent(deployState, cluster);
addDefaultConnectorHostedFilterBinding(cluster);
addAdditionalHostedConnector(deployState, cluster, context);
+ addCloudDataPlaneFilter(deployState, cluster);
+ }
+ }
+
+ private static void addCloudDataPlaneFilter(DeployState deployState, ApplicationContainerCluster cluster) {
+ if (!deployState.isHosted() || !deployState.zone().system().isPublic() || !deployState.featureFlags().enableDataPlaneFilter()) return;
+
+ // Setup secure filter chain
+ var secureChain = new Chain<Filter>(FilterChains.emptyChainSpec(ComponentId.fromString("cloud-data-plane-secure")));
+ secureChain.addInnerComponent(new CloudDataPlaneFilter(cluster, cluster.clientsLegacyMode()));
+ cluster.getHttp().getFilterChains().add(secureChain);
+ // Set cloud data plane filter as default request filter chain for data plane connector
+ cluster.getHttp().getHttpServer().orElseThrow().getConnectorFactories().stream()
+ .filter(c -> c.getListenPort() == HOSTED_VESPA_DATAPLANE_PORT).findAny().orElseThrow()
+ .setDefaultRequestFilterChain(secureChain.getComponentId());
+
+ // Setup insecure filter chain
+ var insecureChain = new Chain<Filter>(FilterChains.emptyChainSpec(ComponentId.fromString("cloud-data-plane-insecure")));
+ insecureChain.addInnerComponent(new Filter(
+ new ChainedComponentModel(
+ new BundleInstantiationSpecification(
+ new ComponentSpecification("com.yahoo.jdisc.http.filter.security.misc.NoopFilter"),
+ null, new ComponentSpecification("jdisc-security-filters")),
+ Dependencies.emptyDependencies())));
+ cluster.getHttp().getFilterChains().add(insecureChain);
+ var insecureChainComponentSpec = new ComponentSpecification(insecureChain.getComponentId().toString());
+ FilterBinding insecureBinding =
+ FilterBinding.create(FilterBinding.Type.REQUEST, insecureChainComponentSpec, VIP_HANDLER_BINDING);
+ cluster.getHttp().getBindings().add(insecureBinding);
+ // Set insecure filter as default request filter chain for default connector
+ cluster.getHttp().getHttpServer().orElseThrow().getConnectorFactories().stream()
+ .filter(c -> c.getListenPort() == Defaults.getDefaults().vespaWebServicePort()).findAny().orElseThrow()
+ .setDefaultRequestFilterChain(insecureChain.getComponentId());
+
+ }
+
+ protected void addClients(DeployState deployState, Element spec, ApplicationContainerCluster cluster, ConfigModelContext context) {
+ if (!deployState.isHosted() || !deployState.zone().system().isPublic() || !deployState.featureFlags().enableDataPlaneFilter()) return;
+
+ List<Client> clients;
+ Element clientsElement = XML.getChild(spec, "clients");
+ boolean legacyMode = false;
+ if (clientsElement == null) {
+ Client defaultClient = new Client("default",
+ List.of(),
+ getCertificates(app.getFile(Path.fromString("security/clients.pem"))));
+ clients = List.of(defaultClient);
+ legacyMode = true;
+ } else {
+ clients = XML.getChildren(clientsElement, "client").stream()
+ .map(this::getCLient)
+ .toList();
+ }
+ cluster.setClients(legacyMode, clients);
+ }
+
+ private Client getCLient(Element clientElement) {
+ String id = XML.attribute("id", clientElement).orElseThrow();
+ List<String> permissions = XML.attribute("permissions", clientElement)
+ .map(p -> p.split(",")).stream()
+ .flatMap(Arrays::stream)
+ .toList();
+
+ List<X509Certificate> x509Certificates = XML.getChildren(clientElement, "certificate").stream()
+ .map(certElem -> Path.fromString(certElem.getAttribute("file")))
+ .map(path -> app.getFile(path))
+ .map(this::getCertificates)
+ .flatMap(Collection::stream)
+ .toList();
+ return new Client(id, permissions, x509Certificates);
+ }
+
+ private List<X509Certificate> getCertificates(ApplicationFile file) {
+ try {
+ InputStream inputStream = file.createInputStream();
+ byte[] bytes = inputStream.readAllBytes();
+ inputStream.close();
+ return X509CertificateUtils.certificateListFromPem(new String(bytes, StandardCharsets.UTF_8));
+ } catch (IOException e) {
+ throw new RuntimeException(e);
}
}
@@ -457,7 +553,10 @@ public class ContainerModelBuilder extends ConfigModelBuilder<ContainerModel> {
boolean proxyProtocolMixedMode = deployState.getProperties().featureFlags().enableProxyProtocolMixedMode();
if (deployState.endpointCertificateSecrets().isPresent()) {
boolean authorizeClient = deployState.zone().system().isPublic();
- if (authorizeClient && deployState.tlsClientAuthority().isEmpty()) {
+ List<X509Certificate> clientCertificates = deployState.featureFlags().enableDataPlaneFilter()
+ ? getClientCertificates(cluster)
+ : deployState.tlsClientAuthority().map(X509CertificateUtils::certificateListFromPem).orElse(List.of());
+ if (authorizeClient && clientCertificates.isEmpty()) {
throw new IllegalArgumentException("Client certificate authority security/clients.pem is missing - " +
"see: https://cloud.vespa.ai/en/security-model#data-plane");
}
@@ -470,7 +569,7 @@ public class ContainerModelBuilder extends ConfigModelBuilder<ContainerModel> {
connectorFactory = authorizeClient
? HostedSslConnectorFactory.withProvidedCertificateAndTruststore(
- serverName, endpointCertificateSecrets, getTlsClientAuthorities(deployState), tlsCiphersOverride, proxyProtocolMixedMode, HOSTED_VESPA_DATAPLANE_PORT)
+ serverName, endpointCertificateSecrets, getTlsClientAuthorities(clientCertificates, deployState), tlsCiphersOverride, proxyProtocolMixedMode, HOSTED_VESPA_DATAPLANE_PORT)
: HostedSslConnectorFactory.withProvidedCertificate(
serverName, endpointCertificateSecrets, enforceHandshakeClientAuth, tlsCiphersOverride, proxyProtocolMixedMode, HOSTED_VESPA_DATAPLANE_PORT);
} else {
@@ -480,15 +579,21 @@ public class ContainerModelBuilder extends ConfigModelBuilder<ContainerModel> {
server.addConnector(connectorFactory);
}
+ // Returns the client certificates defined in
+ private List<X509Certificate> getClientCertificates(ApplicationContainerCluster cluster) {
+ return cluster.getClients()
+ .stream()
+ .map(Client::certificates)
+ .flatMap(Collection::stream)
+ .toList();
+ }
+
/*
Return trusted certificates as a PEM encoded string containing the concatenation of
trusted certs from the application package and all operator certificates.
*/
- String getTlsClientAuthorities(DeployState deployState) {
- List<X509Certificate> trustedCertificates = deployState.tlsClientAuthority()
- .map(X509CertificateUtils::certificateListFromPem)
- .orElse(Collections.emptyList());
- ArrayList<X509Certificate> x509Certificates = new ArrayList<>(trustedCertificates);
+ String getTlsClientAuthorities(List<X509Certificate> applicationCertificates, DeployState deployState) {
+ ArrayList<X509Certificate> x509Certificates = new ArrayList<>(applicationCertificates);
x509Certificates.addAll(deployState.getProperties().operatorCertificates());
return X509CertificateUtils.toPem(x509Certificates);
}
diff --git a/config-model/src/main/resources/schema/containercluster.rnc b/config-model/src/main/resources/schema/containercluster.rnc
index 8cd7071462e..938932c3df6 100644
--- a/config-model/src/main/resources/schema/containercluster.rnc
+++ b/config-model/src/main/resources/schema/containercluster.rnc
@@ -24,7 +24,8 @@ ContainerServices =
AccessLog* &
SecretStore? &
ZooKeeper? &
- GenericConfig*
+ GenericConfig* &
+ Clients?
# TODO(ogronnesby): Change this configuration syntax
ClientAuthorize = element client-authorize { empty }
@@ -128,6 +129,18 @@ Threadpool = element threadpool {
element queue-size { xsd:nonNegativeInteger }
}
+Clients = element clients {
+ Client*
+}
+
+Client = element client {
+ ComponentId &
+ attribute permissions { string } &
+ element certificate {
+ attribute file { string }
+ }+
+}
+
# SEARCH:
SearchInContainer = element search {
diff --git a/config-model/src/test/java/com/yahoo/vespa/model/container/xml/CloudDataPlaneFilterTest.java b/config-model/src/test/java/com/yahoo/vespa/model/container/xml/CloudDataPlaneFilterTest.java
new file mode 100644
index 00000000000..39d2da11465
--- /dev/null
+++ b/config-model/src/test/java/com/yahoo/vespa/model/container/xml/CloudDataPlaneFilterTest.java
@@ -0,0 +1,189 @@
+// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.vespa.model.container.xml;
+
+import com.yahoo.config.model.api.EndpointCertificateSecrets;
+import com.yahoo.config.model.builder.xml.test.DomBuilderTest;
+import com.yahoo.config.model.deploy.DeployState;
+import com.yahoo.config.model.deploy.TestProperties;
+import com.yahoo.config.model.test.MockApplicationPackage;
+import com.yahoo.config.provision.Environment;
+import com.yahoo.config.provision.RegionName;
+import com.yahoo.config.provision.SystemName;
+import com.yahoo.config.provision.Zone;
+import com.yahoo.jdisc.http.ConnectorConfig;
+import com.yahoo.jdisc.http.ServerConfig;
+import com.yahoo.jdisc.http.filter.security.cloud.config.CloudDataPlaneFilterConfig;
+import com.yahoo.security.KeyAlgorithm;
+import com.yahoo.security.KeyUtils;
+import com.yahoo.security.SignatureAlgorithm;
+import com.yahoo.security.X509CertificateBuilder;
+import com.yahoo.security.X509CertificateUtils;
+import com.yahoo.vespa.model.container.ApplicationContainer;
+import com.yahoo.vespa.model.container.ContainerModel;
+import com.yahoo.vespa.model.container.http.ConnectorFactory;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.w3c.dom.Element;
+
+import javax.security.auth.x500.X500Principal;
+import java.io.File;
+import java.io.IOException;
+import java.math.BigInteger;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.KeyPair;
+import java.security.cert.X509Certificate;
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.List;
+import java.util.Optional;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertIterableEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+public class CloudDataPlaneFilterTest extends ContainerModelBuilderTestBase {
+
+ @TempDir
+ public File applicationFolder;
+
+ Path securityFolder;
+ private static final String cloudDataPlaneFilterConfigId = "container/filters/chain/cloud-data-plane-secure/component/" +
+ "com.yahoo.jdisc.http.filter.security.cloud.CloudDataPlaneFilter";
+
+ @BeforeEach
+ public void setup() throws IOException {
+ securityFolder = applicationFolder.toPath().resolve("security");
+ Files.createDirectories(securityFolder);
+ }
+
+ @Test
+ public void it_generates_correct_config() throws IOException {
+ Path certFile = securityFolder.resolve("foo.pem");
+ Element clusterElem = DomBuilderTest.parse(
+ """
+ <container version='1.0'>
+ <clients>
+ <client id="foo" permissions="read,write">
+ <certificate file="%s"/>
+ </client>
+ </clients>
+ </container>
+ """
+ .formatted(applicationFolder.toPath().relativize(certFile).toString()));
+ X509Certificate certificate = createCertificate(certFile);
+
+ buildModel(true, clusterElem);
+
+ CloudDataPlaneFilterConfig config = root.getConfig(CloudDataPlaneFilterConfig.class, cloudDataPlaneFilterConfigId);
+ assertFalse(config.legacyMode());
+ List<CloudDataPlaneFilterConfig.Clients> clients = config.clients();
+ assertEquals(1, clients.size());
+ CloudDataPlaneFilterConfig.Clients client = clients.get(0);
+ assertEquals("foo", client.id());
+ assertIterableEquals(List.of("read", "write"), client.permissions());
+ assertIterableEquals(List.of(X509CertificateUtils.toPem(certificate)), client.certificates());
+
+ ConnectorConfig connectorConfig = connectorConfig();
+ var caCerts = X509CertificateUtils.certificateListFromPem(connectorConfig.ssl().caCertificate());
+ assertEquals(1, caCerts.size());
+ assertEquals(List.of(certificate), caCerts);
+ var srvCfg = root.getConfig(ServerConfig.class, "container/http");
+ assertEquals("cloud-data-plane-insecure", srvCfg.defaultFilters().get(0).filterId());
+ assertEquals(8080, srvCfg.defaultFilters().get(0).localPort());
+ assertEquals("cloud-data-plane-secure", srvCfg.defaultFilters().get(1).filterId());
+ assertEquals(4443, srvCfg.defaultFilters().get(1).localPort());
+ }
+
+ @Test
+ public void it_generates_correct_legacy_config() throws IOException {
+ Path certFile = securityFolder.resolve("clients.pem");
+ Element clusterElem = DomBuilderTest.parse("<container version='1.0' />");
+ X509Certificate certificate = createCertificate(certFile);
+
+ buildModel(true, clusterElem);
+
+ CloudDataPlaneFilterConfig config = root.getConfig(CloudDataPlaneFilterConfig.class, cloudDataPlaneFilterConfigId);
+ assertTrue(config.legacyMode());
+ List<CloudDataPlaneFilterConfig.Clients> clients = config.clients();
+ assertEquals(0, clients.size());
+
+ ConnectorConfig connectorConfig = connectorConfig();
+ var caCerts = X509CertificateUtils.certificateListFromPem(connectorConfig.ssl().caCertificate());
+ assertEquals(1, caCerts.size());
+ assertEquals(List.of(certificate), caCerts);
+ }
+
+ @Test
+ public void it_generates_correct_config_when_filter_not_enabled () throws IOException {
+ Path certFile = securityFolder.resolve("clients.pem");
+ Element clusterElem = DomBuilderTest.parse(
+ """
+ <container version='1.0'>
+ <clients>
+ <client id="foo" permissions="read,write">
+ <certificate file="%s"/>
+ </client>
+ </clients>
+ </container>
+ """
+ .formatted(applicationFolder.toPath().relativize(certFile).toString()));
+ X509Certificate certificate = createCertificate(certFile);
+
+ buildModel(false, clusterElem);
+
+ // Data plane filter config is not configured
+ assertFalse(root.getConfigIds().contains("container/component/com.yahoo.jdisc.http.filter.security.cloud.CloudDataPlaneFilter"));
+
+ // Connector config configures ca certs from security/clients.pem
+ ConnectorConfig connectorConfig = connectorConfig();
+ var caCerts = X509CertificateUtils.certificateListFromPem(connectorConfig.ssl().caCertificate());
+ assertEquals(1, caCerts.size());
+ assertEquals(List.of(certificate), caCerts);
+ }
+
+ private ConnectorConfig connectorConfig() {
+ ApplicationContainer container = (ApplicationContainer) root.getProducer("container/container.0");
+ List<ConnectorFactory> connectorFactories = container.getHttp().getHttpServer().get().getConnectorFactories();
+ ConnectorFactory tlsPort = connectorFactories.stream().filter(connectorFactory -> connectorFactory.getListenPort() == 4443).findFirst().orElseThrow();
+
+ ConnectorConfig.Builder builder = new ConnectorConfig.Builder();
+ tlsPort.getConfig(builder);
+
+ return new ConnectorConfig(builder);
+ }
+
+ /*
+ Creates cert, returns
+ */
+ static X509Certificate createCertificate(Path certFile) throws IOException {
+ KeyPair keyPair = KeyUtils.generateKeypair(KeyAlgorithm.EC, 256);
+ X500Principal subject = new X500Principal("CN=mysubject");
+ X509Certificate certificate = X509CertificateBuilder
+ .fromKeypair(
+ keyPair, subject, Instant.now(), Instant.now().plus(1, ChronoUnit.DAYS), SignatureAlgorithm.SHA512_WITH_ECDSA, BigInteger.valueOf(1))
+ .build();
+ String certPem = X509CertificateUtils.toPem(certificate);
+ Files.writeString(certFile, certPem);
+ return certificate;
+ }
+
+ public List<ContainerModel> buildModel(boolean enableFilter, Element... clusterElem) {
+ var applicationPackage = new MockApplicationPackage.Builder()
+ .withRoot(applicationFolder)
+ .build();
+
+ DeployState state = new DeployState.Builder()
+ .applicationPackage(applicationPackage)
+ .properties(
+ new TestProperties()
+ .setEndpointCertificateSecrets(Optional.of(new EndpointCertificateSecrets("CERT", "KEY")))
+ .setHostedVespa(true)
+ .setEnableDataPlaneFilter(enableFilter))
+ .zone(new Zone(SystemName.PublicCd, Environment.dev, RegionName.defaultName()))
+ .build();
+ return createModel(root, state, null, clusterElem);
+ }
+}
diff --git a/config-model/src/test/schema-test-files/services.xml b/config-model/src/test/schema-test-files/services.xml
index 543f76ca136..7976b1f5524 100644
--- a/config-model/src/test/schema-test-files/services.xml
+++ b/config-model/src/test/schema-test-files/services.xml
@@ -209,6 +209,16 @@
</environment-variables>
<node hostalias="host1" />
</nodes>
+
+ <clients>
+ <client id="client1" permissions="read,write">
+ <certificate file="security/file.pem" />
+ </client>
+ <client id="client2" permissions="write">
+ <certificate file="security/file1.pem" />
+ <certificate file="security/file2.pem" />
+ </client>
+ </clients>
</container>
<container id='qrsCluster_2' version='1.0'>
diff --git a/configdefinitions/src/main/java/com/yahoo/jdisc/http/filter/security/cloud/config/package-info.java b/configdefinitions/src/main/java/com/yahoo/jdisc/http/filter/security/cloud/config/package-info.java
new file mode 100644
index 00000000000..2a21b128bb5
--- /dev/null
+++ b/configdefinitions/src/main/java/com/yahoo/jdisc/http/filter/security/cloud/config/package-info.java
@@ -0,0 +1,6 @@
+// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+
+@ExportPackage
+package com.yahoo.jdisc.http.filter.security.cloud.config;
+
+import com.yahoo.osgi.annotation.ExportPackage; \ No newline at end of file
diff --git a/configdefinitions/src/vespa/cloud-data-plane-filter.def b/configdefinitions/src/vespa/cloud-data-plane-filter.def
new file mode 100644
index 00000000000..47478a28039
--- /dev/null
+++ b/configdefinitions/src/vespa/cloud-data-plane-filter.def
@@ -0,0 +1,7 @@
+# Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+namespace=jdisc.http.filter.security.cloud.config
+
+legacyMode bool default=false
+clients[].id string
+clients[].permissions[] string
+clients[].certificates[] string
diff --git a/configserver/src/main/java/com/yahoo/vespa/config/server/deploy/ModelContextImpl.java b/configserver/src/main/java/com/yahoo/vespa/config/server/deploy/ModelContextImpl.java
index 9750fe7d120..2a19a3b0833 100644
--- a/configserver/src/main/java/com/yahoo/vespa/config/server/deploy/ModelContextImpl.java
+++ b/configserver/src/main/java/com/yahoo/vespa/config/server/deploy/ModelContextImpl.java
@@ -210,6 +210,7 @@ public class ModelContextImpl implements ModelContext {
private final boolean useRestrictedDataPlaneBindings;
private final int heapPercentage;
private final boolean useOldJdiscContainerStartup;
+ private final boolean enableDataPlaneFilter;
public FeatureFlags(FlagSource source, ApplicationId appId, Version version) {
this.defaultTermwiseLimit = flagValue(source, appId, version, Flags.DEFAULT_TERM_WISE_LIMIT);
@@ -256,6 +257,7 @@ public class ModelContextImpl implements ModelContext {
this.useRestrictedDataPlaneBindings = flagValue(source, appId, version, Flags.RESTRICT_DATA_PLANE_BINDINGS);
this.heapPercentage = flagValue(source, appId, version, PermanentFlags.HEAP_SIZE_PERCENTAGE);
this.useOldJdiscContainerStartup = flagValue(source, appId, version, Flags.USE_OLD_JDISC_CONTAINER_STARTUP);
+ this.enableDataPlaneFilter = flagValue(source, appId, version, Flags.ENABLE_DATAPLANE_FILTER);
}
@Override public boolean useOldJdiscContainerStartup() { return useOldJdiscContainerStartup; }
@@ -310,6 +312,7 @@ public class ModelContextImpl implements ModelContext {
}
@Override public boolean useTwoPhaseDocumentGc() { return useTwoPhaseDocumentGc; }
@Override public boolean useRestrictedDataPlaneBindings() { return useRestrictedDataPlaneBindings; }
+ @Override public boolean enableDataPlaneFilter() { return enableDataPlaneFilter; }
private static <V> V flagValue(FlagSource source, ApplicationId appId, Version vespaVersion, UnboundFlag<? extends V, ?, ?> flag) {
return flag.bindTo(source)
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 2f8efd5a717..a53f13e1303 100644
--- a/flags/src/main/java/com/yahoo/vespa/flags/Flags.java
+++ b/flags/src/main/java/com/yahoo/vespa/flags/Flags.java
@@ -424,6 +424,13 @@ public class Flags {
"If true, use the old vespa-start-container-daemon script.",
"Takes effect immediately?",
ZONE_ID, APPLICATION_ID);
+
+ public static final UnboundBooleanFlag ENABLE_DATAPLANE_FILTER = defineFeatureFlag(
+ "enable-dataplane-filter", false,
+ List.of("tokle", "bjorncs"), "2022-11-15", "2023-01-31",
+ "Setup data plane filter from clients configuration",
+ "Takes effect on redeployment",
+ APPLICATION_ID);
public static final UnboundBooleanFlag USE_LOCKS_IN_FILEDISTRIBUTION = defineFeatureFlag(
"use-locks-in-filedistribution", false,
diff --git a/jdisc-security-filters/src/main/java/com/yahoo/jdisc/http/filter/security/cloud/CloudDataPlaneFilter.java b/jdisc-security-filters/src/main/java/com/yahoo/jdisc/http/filter/security/cloud/CloudDataPlaneFilter.java
new file mode 100644
index 00000000000..04446ddd4de
--- /dev/null
+++ b/jdisc-security-filters/src/main/java/com/yahoo/jdisc/http/filter/security/cloud/CloudDataPlaneFilter.java
@@ -0,0 +1,146 @@
+// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.jdisc.http.filter.security.cloud;
+
+import com.yahoo.component.annotation.Inject;
+import com.yahoo.container.jdisc.AclMapping;
+import com.yahoo.container.jdisc.RequestHandlerSpec;
+import com.yahoo.container.jdisc.RequestView;
+import com.yahoo.jdisc.Response;
+import com.yahoo.jdisc.http.filter.DiscFilterRequest;
+import com.yahoo.jdisc.http.filter.security.base.JsonSecurityRequestFilterBase;
+import com.yahoo.jdisc.http.filter.security.cloud.config.CloudDataPlaneFilterConfig;
+import com.yahoo.security.X509CertificateUtils;
+
+import java.security.Principal;
+import java.security.cert.X509Certificate;
+import java.util.ArrayList;
+import java.util.EnumSet;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+import java.util.TreeSet;
+import java.util.logging.Logger;
+import java.util.stream.Collectors;
+
+import static com.yahoo.jdisc.http.filter.security.cloud.CloudDataPlaneFilter.Permission.READ;
+import static com.yahoo.jdisc.http.filter.security.cloud.CloudDataPlaneFilter.Permission.WRITE;
+
+/**
+ * @author bjorncs
+ */
+public class CloudDataPlaneFilter extends JsonSecurityRequestFilterBase {
+
+ private static final Logger log = Logger.getLogger(CloudDataPlaneFilter.class.getName());
+
+ private final boolean legacyMode;
+ private final List<Client> allowedClients;
+
+ @Inject
+ public CloudDataPlaneFilter(CloudDataPlaneFilterConfig cfg) {
+ this.legacyMode = cfg.legacyMode();
+ if (legacyMode) {
+ allowedClients = List.of();
+ log.fine(() -> "Legacy mode enabled");
+ } else {
+ allowedClients = parseClients(cfg);
+ }
+ }
+
+ private static List<Client> parseClients(CloudDataPlaneFilterConfig cfg) {
+ Set<String> ids = new HashSet<>();
+ List<Client> clients = new ArrayList<>(cfg.clients().size());
+ for (var c : cfg.clients()) {
+ if (ids.contains(c.id()))
+ throw new IllegalArgumentException("Clients definition has duplicate id '%s'".formatted(c.id()));
+ ids.add(c.id());
+ List<X509Certificate> certs;
+ try {
+ certs = c.certificates().stream().map(X509CertificateUtils::fromPem).toList();
+ } catch (Exception e) {
+ throw new IllegalArgumentException(
+ "Client '%s' contains invalid X.509 certificate PEM: %s".formatted(c.id(), e.toString()), e);
+ }
+ EnumSet<Permission> permissions = c.permissions().stream().map(Permission::of)
+ .collect(Collectors.toCollection(() -> EnumSet.noneOf(Permission.class)));
+ clients.add(new Client(c.id(), permissions, certs));
+ }
+ if (clients.isEmpty()) throw new IllegalArgumentException("Empty clients configuration");
+ log.fine(() -> "Configured clients with ids %s".formatted(ids));
+ return clients;
+ }
+
+ @Override
+ protected Optional<ErrorResponse> filter(DiscFilterRequest req) {
+ var certs = req.getClientCertificateChain();
+ log.fine(() -> "Certificate chain contains %d elements".formatted(certs.size()));
+ if (certs.isEmpty()) {
+ log.fine("Missing client certificate");
+ return Optional.of(new ErrorResponse(Response.Status.UNAUTHORIZED, "Unauthorized"));
+ }
+ if (legacyMode) {
+ log.fine("Legacy mode validation complete");
+ req.setUserPrincipal(new ClientPrincipal(Set.of(), Set.of(READ, WRITE)));
+ return Optional.empty();
+ }
+ RequestView view = req.asRequestView();
+ var permission = Optional.ofNullable((RequestHandlerSpec) req.getAttribute(RequestHandlerSpec.ATTRIBUTE_NAME))
+ .or(() -> Optional.of(RequestHandlerSpec.DEFAULT_INSTANCE))
+ .flatMap(spec -> {
+ var action = spec.aclMapping().get(view);
+ var maybePermission = Permission.of(action);
+ if (maybePermission.isEmpty()) log.fine(() -> "Unknown action '%s'".formatted(action));
+ return maybePermission;
+ }).orElse(null);
+ if (permission == null) {
+ log.fine(() -> "No valid permission mapping defined for %s @ '%s'".formatted(view.method(), view.uri()));
+ return Optional.of(new ErrorResponse(Response.Status.FORBIDDEN, "Forbidden"));
+ }
+ var clientCert = certs.get(0);
+ var clientIds = new TreeSet<String>();
+ var permissions = new TreeSet<Permission>();
+ for (Client c : allowedClients) {
+ if (c.permissions().contains(permission) && c.certificates().contains(clientCert)) {
+ clientIds.add(c.id());
+ permissions.addAll(c.permissions());
+ }
+ }
+ log.fine(() -> "Client with ids=%s, permissions=%s"
+ .formatted(clientIds, permissions.stream().map(Permission::asString).toList()));
+ if (clientIds.isEmpty()) return Optional.of(new ErrorResponse(Response.Status.FORBIDDEN, "Forbidden"));
+ req.setUserPrincipal(new ClientPrincipal(clientIds, permissions));
+ return Optional.empty();
+ }
+
+ public record ClientPrincipal(Set<String> ids, Set<Permission> permissions) implements Principal {
+ public ClientPrincipal { ids = Set.copyOf(ids); permissions = Set.copyOf(permissions); }
+ @Override public String getName() {
+ return "ids=%s,permissions=%s".formatted(ids, permissions.stream().map(Permission::asString).toList());
+ }
+ }
+
+ enum Permission { READ, WRITE;
+ String asString() {
+ return switch (this) {
+ case READ -> "read";
+ case WRITE -> "write";
+ };
+ }
+ static Permission of(String v) {
+ return switch (v) {
+ case "read" -> READ;
+ case "write" -> WRITE;
+ default -> throw new IllegalArgumentException("Invalid permission '%s'".formatted(v));
+ };
+ }
+ static Optional<Permission> of(AclMapping.Action a) {
+ if (a.equals(AclMapping.Action.READ)) return Optional.of(READ);
+ if (a.equals(AclMapping.Action.WRITE)) return Optional.of(WRITE);
+ return Optional.empty();
+ }
+ }
+
+ private record Client(String id, EnumSet<Permission> permissions, List<X509Certificate> certificates) {
+ Client { permissions = EnumSet.copyOf(permissions); certificates = List.copyOf(certificates); }
+ }
+}
diff --git a/jdisc-security-filters/src/main/java/com/yahoo/jdisc/http/filter/security/cloud/package-info.java b/jdisc-security-filters/src/main/java/com/yahoo/jdisc/http/filter/security/cloud/package-info.java
new file mode 100644
index 00000000000..a4b2a23ea95
--- /dev/null
+++ b/jdisc-security-filters/src/main/java/com/yahoo/jdisc/http/filter/security/cloud/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.jdisc.http.filter.security.cloud;
+
+import com.yahoo.osgi.annotation.ExportPackage; \ No newline at end of file
diff --git a/jdisc-security-filters/src/test/java/com/yahoo/jdisc/http/filter/security/cloud/CloudDataPlaneFilterTest.java b/jdisc-security-filters/src/test/java/com/yahoo/jdisc/http/filter/security/cloud/CloudDataPlaneFilterTest.java
new file mode 100644
index 00000000000..d8b6312e90e
--- /dev/null
+++ b/jdisc-security-filters/src/test/java/com/yahoo/jdisc/http/filter/security/cloud/CloudDataPlaneFilterTest.java
@@ -0,0 +1,151 @@
+// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.jdisc.http.filter.security.cloud;
+
+import com.yahoo.container.jdisc.AclMapping.Action;
+import com.yahoo.container.jdisc.HttpMethodAclMapping;
+import com.yahoo.container.jdisc.RequestHandlerSpec;
+import com.yahoo.container.jdisc.RequestHandlerTestDriver.MockResponseHandler;
+import com.yahoo.jdisc.http.HttpRequest.Method;
+import com.yahoo.jdisc.http.filter.security.cloud.CloudDataPlaneFilter.ClientPrincipal;
+import com.yahoo.jdisc.http.filter.security.cloud.config.CloudDataPlaneFilterConfig;
+import com.yahoo.jdisc.http.filter.util.FilterTestUtils;
+import com.yahoo.security.KeyUtils;
+import com.yahoo.security.X509CertificateBuilder;
+import com.yahoo.security.X509CertificateUtils;
+import org.junit.jupiter.api.Test;
+
+import javax.security.auth.x500.X500Principal;
+import java.math.BigInteger;
+import java.security.cert.X509Certificate;
+import java.util.List;
+import java.util.Set;
+
+import static com.yahoo.jdisc.Response.Status.FORBIDDEN;
+import static com.yahoo.jdisc.Response.Status.UNAUTHORIZED;
+import static com.yahoo.jdisc.http.filter.security.cloud.CloudDataPlaneFilter.Permission.READ;
+import static com.yahoo.jdisc.http.filter.security.cloud.CloudDataPlaneFilter.Permission.WRITE;
+import static com.yahoo.security.KeyAlgorithm.EC;
+import static com.yahoo.security.SignatureAlgorithm.SHA256_WITH_ECDSA;
+import static java.time.Instant.EPOCH;
+import static java.time.temporal.ChronoUnit.DAYS;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+/**
+ * @author bjorncs
+ */
+class CloudDataPlaneFilterTest {
+
+ private static final X509Certificate FEED_CERT = certificate("my-feed-client");
+ private static final X509Certificate SEARCH_CERT = certificate("my-search-client");
+ private static final X509Certificate LEGACY_CLIENT = certificate("my-legacy-client");
+ private static final String FEED_CLIENT_ID = "feed-client";
+ private static final String SEARCH_CLIENT_ID = "search-client";
+
+ @Test
+ void accepts_any_trusted_client_certificate_in_legacy_mode() {
+ var req = FilterTestUtils.newRequestBuilder().withClientCertificate(LEGACY_CLIENT).build();
+ var responseHandler = new MockResponseHandler();
+ newFilterWithLegacyMode().filter(req, responseHandler);
+ assertNull(responseHandler.getResponse());
+ assertEquals(new ClientPrincipal(Set.of(), Set.of(READ, WRITE)), req.getUserPrincipal());
+ }
+
+ @Test
+ void fails_on_missing_certificate_in_legacy_mode() {
+ var req = FilterTestUtils.newRequestBuilder().build();
+ var responseHandler = new MockResponseHandler();
+ newFilterWithLegacyMode().filter(req, responseHandler);
+ assertNotNull(responseHandler.getResponse());
+ assertEquals(UNAUTHORIZED, responseHandler.getResponse().getStatus());
+ }
+
+ @Test
+ void accepts_client_with_valid_certificate() {
+ var req = FilterTestUtils.newRequestBuilder()
+ .withMethod(Method.POST)
+ .withClientCertificate(FEED_CERT)
+ .build();
+ var responseHandler = new MockResponseHandler();
+ newFilterWithClientsConfig().filter(req, responseHandler);
+ assertNull(responseHandler.getResponse());
+ assertEquals(new ClientPrincipal(Set.of(FEED_CLIENT_ID), Set.of(WRITE)), req.getUserPrincipal());
+ }
+
+ @Test
+ void fails_on_client_with_invalid_permissions() {
+ var req = FilterTestUtils.newRequestBuilder()
+ .withMethod(Method.POST)
+ .withClientCertificate(SEARCH_CERT)
+ .build();
+ var responseHandler = new MockResponseHandler();
+ newFilterWithClientsConfig().filter(req, responseHandler);
+ assertNotNull(responseHandler.getResponse());
+ assertEquals(FORBIDDEN, responseHandler.getResponse().getStatus());
+ }
+
+ @Test
+ void supports_handler_with_custom_request_spec() {
+ // Spec that maps POST as action 'read'
+ var spec = RequestHandlerSpec.builder()
+ .withAclMapping(HttpMethodAclMapping.standard()
+ .override(Method.POST, Action.READ).build())
+ .build();
+ var req = FilterTestUtils.newRequestBuilder()
+ .withMethod(Method.POST)
+ .withClientCertificate(SEARCH_CERT)
+ .withAttribute(RequestHandlerSpec.ATTRIBUTE_NAME, spec)
+ .build();
+ var responseHandler = new MockResponseHandler();
+ newFilterWithClientsConfig().filter(req, responseHandler);
+ assertNull(responseHandler.getResponse());
+ assertEquals(new ClientPrincipal(Set.of(SEARCH_CLIENT_ID), Set.of(READ)), req.getUserPrincipal());
+ }
+
+ @Test
+ void fails_on_handler_with_custom_request_spec_with_invalid_action() {
+ // Spec that maps POST as action 'read'
+ var spec = RequestHandlerSpec.builder()
+ .withAclMapping(HttpMethodAclMapping.standard()
+ .override(Method.GET, Action.custom("custom")).build())
+ .build();
+ var req = FilterTestUtils.newRequestBuilder()
+ .withMethod(Method.GET)
+ .withClientCertificate(SEARCH_CERT)
+ .withAttribute(RequestHandlerSpec.ATTRIBUTE_NAME, spec)
+ .build();
+ var responseHandler = new MockResponseHandler();
+ newFilterWithClientsConfig().filter(req, responseHandler);
+ assertNotNull(responseHandler.getResponse());
+ assertEquals(FORBIDDEN, responseHandler.getResponse().getStatus());
+ }
+
+ private static CloudDataPlaneFilter newFilterWithLegacyMode() {
+ return new CloudDataPlaneFilter(new CloudDataPlaneFilterConfig.Builder().legacyMode(true).build());
+ }
+
+ private static CloudDataPlaneFilter newFilterWithClientsConfig() {
+ return new CloudDataPlaneFilter(
+ new CloudDataPlaneFilterConfig.Builder()
+ .clients(List.of(
+ new CloudDataPlaneFilterConfig.Clients.Builder()
+ .certificates(X509CertificateUtils.toPem(FEED_CERT))
+ .permissions(WRITE.asString())
+ .id(FEED_CLIENT_ID),
+ new CloudDataPlaneFilterConfig.Clients.Builder()
+ .certificates(X509CertificateUtils.toPem(SEARCH_CERT))
+ .permissions(READ.asString())
+ .id(SEARCH_CLIENT_ID)))
+ .build());
+ }
+
+ private static X509Certificate certificate(String name) {
+ var key = KeyUtils.generateKeypair(EC);
+ var subject = new X500Principal("CN=%s".formatted(name));
+ return X509CertificateBuilder
+ .fromKeypair(key, subject, EPOCH, EPOCH.plus(1, DAYS), SHA256_WITH_ECDSA, BigInteger.ONE).build();
+ }
+
+
+} \ No newline at end of file