diff options
author | Bjørn Christian Seime <bjorncs@yahooinc.com> | 2023-06-15 16:49:48 +0200 |
---|---|---|
committer | Bjørn Christian Seime <bjorncs@yahooinc.com> | 2023-06-15 16:49:48 +0200 |
commit | 4611c4e3ab75f2a3cd291495fc8f0d4f32425807 (patch) | |
tree | 85faff4e70b90d67195b28f69cce6d436785247b | |
parent | 12da7ac0dc15cc66b6c6a9621472d2ec8e1e73d0 (diff) |
Add token support to `client` configuration
8 files changed, 159 insertions, 38 deletions
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 0e39b7b5c3a..2b55b1f1d10 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 @@ -11,12 +11,14 @@ import com.yahoo.config.provision.ApplicationId; import com.yahoo.config.provision.AthenzDomain; import com.yahoo.config.provision.CloudAccount; import com.yahoo.config.provision.ClusterSpec; +import com.yahoo.config.provision.DataplaneToken; import com.yahoo.config.provision.HostName; import com.yahoo.config.provision.Zone; import com.yahoo.vespa.model.container.ApplicationContainerCluster; import java.net.URI; import java.security.cert.X509Certificate; +import java.util.Collection; import java.util.Collections; import java.util.List; import java.util.Optional; @@ -85,6 +87,7 @@ public class TestProperties implements ModelContext.Properties, ModelContext.Fea private boolean allowUserFilters = true; private boolean allowMoreThanOneContentGroupDown = false; private boolean enableConditionalPutRemoveWriteRepair = false; + private List<DataplaneToken> dataplaneTokens; @Override public ModelContext.FeatureFlags featureFlags() { return this; } @Override public boolean multitenant() { return multitenant; } @@ -144,6 +147,7 @@ public class TestProperties implements ModelContext.Properties, ModelContext.Fea @Override public boolean enableGlobalPhase() { return true; } // Enable global-phase by default for unit tests only @Override public boolean allowMoreThanOneContentGroupDown(ClusterSpec.Id id) { return allowMoreThanOneContentGroupDown; } @Override public boolean enableConditionalPutRemoveWriteRepair() { return enableConditionalPutRemoveWriteRepair; } + @Override public List<DataplaneToken> dataplaneTokens() { return dataplaneTokens; } public TestProperties sharedStringRepoNoReclaim(boolean sharedStringRepoNoReclaim) { this.sharedStringRepoNoReclaim = sharedStringRepoNoReclaim; @@ -384,6 +388,11 @@ public class TestProperties implements ModelContext.Properties, ModelContext.Fea public TestProperties setAllowUserFilters(boolean b) { this.allowUserFilters = b; return this; } + public TestProperties setDataplaneTokens(Collection<DataplaneToken> tokens) { + this.dataplaneTokens = List.copyOf(tokens); + 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/http/Client.java b/config-model/src/main/java/com/yahoo/vespa/model/container/http/Client.java index 7707949714e..1388e9647a6 100644 --- 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 @@ -1,6 +1,8 @@ // 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 com.yahoo.config.provision.DataplaneToken; + import java.security.cert.X509Certificate; import java.util.List; @@ -10,19 +12,22 @@ import java.util.List; * @author mortent */ public class Client { - private String id; - private List<String> permissions; - private List<X509Certificate> certificates; - private boolean internal; - - public Client(String id, List<String> permissions, List<X509Certificate> certificates) { - this(id, permissions, certificates, false); + private final String id; + private final List<String> permissions; + private final List<X509Certificate> certificates; + private final List<DataplaneToken> tokens; + private final boolean internal; + + public Client(String id, List<String> permissions, List<X509Certificate> certificates, List<DataplaneToken> tokens) { + this(id, permissions, certificates, tokens, false); } - private Client(String id, List<String> permissions, List<X509Certificate> certificates, boolean internal) { + private Client(String id, List<String> permissions, List<X509Certificate> certificates, List<DataplaneToken> tokens, + boolean internal) { this.id = id; - this.permissions = permissions; - this.certificates = certificates; + this.permissions = List.copyOf(permissions); + this.certificates = List.copyOf(certificates); + this.tokens = List.copyOf(tokens); this.internal = internal; } @@ -38,11 +43,13 @@ public class Client { return certificates; } + public List<DataplaneToken> tokens() { return tokens; } + public boolean internal() { return internal; } public static Client internalClient(List<X509Certificate> certificates) { - return new Client("_internal", List.of("read","write"), certificates, true); + return new Client("_internal", List.of("read","write"), certificates, List.of(), true); } } 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 index 6767a61d02b..548e05014d0 100644 --- 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 @@ -4,27 +4,40 @@ 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.config.model.deploy.DeployState; +import com.yahoo.config.provision.DataplaneToken; import com.yahoo.container.bundle.BundleInstantiationSpecification; import com.yahoo.jdisc.http.filter.security.cloud.config.CloudDataPlaneFilterConfig; import com.yahoo.security.X509CertificateUtils; +import com.yahoo.security.token.TokenDomain; 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.Collection; import java.util.List; +import static com.yahoo.security.ArrayUtils.concat; +import static com.yahoo.security.ArrayUtils.fromUtf8Bytes; +import static java.nio.charset.StandardCharsets.UTF_8; + 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; + private final Collection<Client> clients; + private final boolean clientsLegacyMode; + private final String tokenContext; - CloudDataPlaneFilter(ApplicationContainerCluster cluster, boolean legacyMode) { + CloudDataPlaneFilter(ApplicationContainerCluster cluster, DeployState state) { super(model()); - this.cluster = cluster; - this.legacyMode = legacyMode; + this.clients = List.copyOf(cluster.getClients()); + this.clientsLegacyMode = cluster.clientsLegacyMode(); + // Token domain must be identical to the domain used for generating the tokens + this.tokenContext = fromUtf8Bytes(TokenDomain.of(fromUtf8Bytes(concat( + "Vespa Cloud tenant data plane:".getBytes(UTF_8), + state.getProperties().applicationId().tenant().value().getBytes(UTF_8)))).checkHashContext()); } private static ChainedComponentModel model() { @@ -36,18 +49,26 @@ class CloudDataPlaneFilter extends Filter implements CloudDataPlaneFilterConfig. @Override public void getConfig(CloudDataPlaneFilterConfig.Builder builder) { - if (legacyMode) { + if (clientsLegacyMode) { builder.legacyMode(true); } else { - List<Client> clients = cluster.getClients(); - builder.legacyMode(false); - List<CloudDataPlaneFilterConfig.Clients.Builder> clientsList = clients.stream() + var clientsCfg = clients.stream() .map(x -> new CloudDataPlaneFilterConfig.Clients.Builder() .id(x.id()) .certificates(X509CertificateUtils.toPem(x.certificates())) + .tokens(tokensConfig(x.tokens())) .permissions(x.permissions())) .toList(); - builder.clients(clientsList); + builder.clients(clientsCfg).legacyMode(false).tokenContext(tokenContext); } } + + private static List<CloudDataPlaneFilterConfig.Clients.Tokens.Builder> tokensConfig(Collection<DataplaneToken> tokens) { + return tokens.stream() + .map(token -> new CloudDataPlaneFilterConfig.Clients.Tokens.Builder() + .id(token.tokenId()) + .fingerprints(token.versions().stream().map(DataplaneToken.Version::fingerprint).toList()) + .checkAccessHashes(token.versions().stream().map(DataplaneToken.Version::checkAccessHash).toList())) + .toList(); + } } 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 adf805a9d10..2b5232eba8c 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 @@ -29,6 +29,7 @@ import com.yahoo.config.provision.AthenzService; import com.yahoo.config.provision.Capacity; import com.yahoo.config.provision.ClusterMembership; import com.yahoo.config.provision.ClusterSpec; +import com.yahoo.config.provision.DataplaneToken; import com.yahoo.config.provision.HostName; import com.yahoo.config.provision.InstanceName; import com.yahoo.config.provision.NodeType; @@ -121,6 +122,7 @@ import java.util.Optional; import java.util.OptionalInt; import java.util.Set; import java.util.function.Consumer; +import java.util.function.Function; import java.util.logging.Level; import java.util.regex.Pattern; import java.util.stream.Collectors; @@ -471,7 +473,7 @@ public class ContainerModelBuilder extends ConfigModelBuilder<ContainerModel> { var dataplanePort = getDataplanePort(deployState); // Setup secure filter chain var secureChain = new HttpFilterChain("cloud-data-plane-secure", HttpFilterChain.Type.SYSTEM); - secureChain.addInnerComponent(new CloudDataPlaneFilter(cluster, cluster.clientsLegacyMode())); + secureChain.addInnerComponent(new CloudDataPlaneFilter(cluster, deployState)); 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() @@ -505,15 +507,16 @@ public class ContainerModelBuilder extends ConfigModelBuilder<ContainerModel> { 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); + clients = List.of(new Client( + "default", List.of(), getCertificates(app.getFile(Path.fromString("security/clients.pem"))), List.of())); legacyMode = true; } else { clients = XML.getChildren(clientsElement, "client").stream() - .map(this::getClient) + .flatMap(elem -> getClient(elem, deployState).stream()) .toList(); + boolean atLeastOneClientWithCertificate = clients.stream().anyMatch(client -> !client.certificates().isEmpty()); + if (!atLeastOneClientWithCertificate) + throw new IllegalArgumentException("At least one client must require a certificate"); } List<X509Certificate> operatorAndTesterCertificates = deployState.getProperties().operatorCertificates(); @@ -522,9 +525,9 @@ public class ContainerModelBuilder extends ConfigModelBuilder<ContainerModel> { cluster.setClients(legacyMode, clients); } - private Client getClient(Element clientElement) { - String id = XML.attribute("id", clientElement).orElseThrow(); - if (id.startsWith("_")) throw new IllegalArgumentException("Invalid client id '%s', id cannot start with '_'".formatted(id)); + private Optional<Client> getClient(Element clientElement, DeployState state) { + String clientId = XML.attribute("id", clientElement).orElseThrow(); + if (clientId.startsWith("_")) throw new IllegalArgumentException("Invalid client id '%s', id cannot start with '_'".formatted(clientId)); List<String> permissions = XML.attribute("permissions", clientElement) .map(p -> p.split(",")).stream() .flatMap(Arrays::stream) @@ -535,12 +538,43 @@ public class ContainerModelBuilder extends ConfigModelBuilder<ContainerModel> { var file = app.getFile(Path.fromString(certElem.getAttribute("file"))); if (!file.exists()) { throw new IllegalArgumentException("Certificate file '%s' for client '%s' does not exist" - .formatted(file.getPath().getRelative(), id)); + .formatted(file.getPath().getRelative(), clientId)); } return getCertificates(file).stream(); }) .toList(); - return new Client(id, permissions, certificates); + // A client cannot use both tokens and certificates + if (!certificates.isEmpty()) return Optional.of(new Client(clientId, permissions, certificates, List.of())); + + var knownTokens = state.getProperties().dataplaneTokens().stream() + .collect(Collectors.toMap(DataplaneToken::tokenId, Function.identity())); + + var referencedTokens = XML.getChildren(clientElement, "token").stream() + .map(elem -> { + var tokenId = elem.getAttribute("id"); + var token = knownTokens.get(tokenId); + if (token == null) + throw new IllegalArgumentException( + "Token '%s' for client '%s' does not exist".formatted(tokenId, clientId)); + return token; + }) + .filter(token -> { + boolean empty = token.versions().isEmpty(); + if (empty) + log.logApplicationPackage( + WARNING, "Token '%s' for client '%s' has no activate versions" + .formatted(token.tokenId(), clientId)); + return !empty; + }) + .toList(); + + // Don't include 'client' that refers to token without versions + if (referencedTokens.isEmpty()) { + log.log(Level.INFO, "Skipping client '%s' as it does not refer to any activate tokens".formatted(clientId)); + return Optional.empty(); + } + + return Optional.of(new Client(clientId, permissions, List.of(), referencedTokens)); } private List<X509Certificate> getCertificates(ApplicationFile file) { diff --git a/config-model/src/main/resources/schema/containercluster.rnc b/config-model/src/main/resources/schema/containercluster.rnc index b8c02b013aa..285911549d7 100644 --- a/config-model/src/main/resources/schema/containercluster.rnc +++ b/config-model/src/main/resources/schema/containercluster.rnc @@ -140,9 +140,11 @@ Clients = element clients { Client = element client { ComponentId & attribute permissions { string } & - element certificate { - attribute file { string } - }+ + ( + element certificate { attribute file { string } }+ + | + element token { attribute id { string } }+ + ) } # SEARCH: diff --git a/config-model/src/test/java/com/yahoo/vespa/model/application/validation/change/CertificateRemovalChangeValidatorTest.java b/config-model/src/test/java/com/yahoo/vespa/model/application/validation/change/CertificateRemovalChangeValidatorTest.java index b955ada20d9..d8dfe204453 100644 --- a/config-model/src/test/java/com/yahoo/vespa/model/application/validation/change/CertificateRemovalChangeValidatorTest.java +++ b/config-model/src/test/java/com/yahoo/vespa/model/application/validation/change/CertificateRemovalChangeValidatorTest.java @@ -31,9 +31,9 @@ public class CertificateRemovalChangeValidatorTest { void validate() { Instant now = LocalDate.parse("2000-01-01", DateTimeFormatter.ISO_DATE).atStartOfDay().atZone(ZoneOffset.UTC).toInstant(); - Client c1 = new Client("c1", List.of(), List.of(certificate("cn=c1"))); - Client c2 = new Client("c2", List.of(), List.of(certificate("cn=c2"))); - Client c3 = new Client("c3", List.of(), List.of(certificate("cn=c3"))); + Client c1 = new Client("c1", List.of(), List.of(certificate("cn=c1")), List.of()); + Client c2 = new Client("c2", List.of(), List.of(certificate("cn=c2")), List.of()); + Client c3 = new Client("c3", List.of(), List.of(certificate("cn=c3")), List.of()); Client internal = Client.internalClient(List.of(certificate("cn=internal"))); CertificateRemovalChangeValidator validator = new CertificateRemovalChangeValidator(); 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 index 94d92b355f9..5bb0254f1cc 100644 --- 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 @@ -6,6 +6,7 @@ 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.DataplaneToken; import com.yahoo.config.provision.Environment; import com.yahoo.config.provision.RegionName; import com.yahoo.config.provision.SystemName; @@ -36,12 +37,14 @@ import java.security.KeyPair; import java.security.cert.X509Certificate; import java.time.Instant; import java.time.temporal.ChronoUnit; +import java.util.Collection; 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.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -118,6 +121,41 @@ public class CloudDataPlaneFilterTest extends ContainerModelBuilderTestBase { } @Test + void generates_correct_config_for_tokens() throws IOException { + var certFile = securityFolder.resolve("foo.pem"); + var clusterElem = DomBuilderTest.parse( + """ + <container version='1.0'> + <clients> + <client id="foo" permissions="read,write"> + <certificate file="%s"/> + </client> + <client id="bar" permissions="read"> + <token id="my-token"/> + </client> + </clients> + </container> + """ + .formatted(applicationFolder.toPath().relativize(certFile).toString())); + createCertificate(certFile); + buildModel(clusterElem); + + var cfg = root.getConfig(CloudDataPlaneFilterConfig.class, cloudDataPlaneFilterConfigId); + var tokenClient = cfg.clients().stream().filter(c -> c.id().equals("bar")).findAny().orElse(null); + assertNotNull(tokenClient); + assertEquals(List.of("read"), tokenClient.permissions()); + var expectedTokenCfg = tokenConfig( + "my-token", List.of("myfingerprint1", "myfingerprint2"), List.of("myaccesshash1", "myaccesshash2")); + assertEquals(List.of(expectedTokenCfg), tokenClient.tokens()); + } + + private static CloudDataPlaneFilterConfig.Clients.Tokens tokenConfig( + String id, Collection<String> fingerprints, Collection<String> accessCheckHashes) { + return new CloudDataPlaneFilterConfig.Clients.Tokens.Builder() + .id(id).fingerprints(fingerprints).checkAccessHashes(accessCheckHashes).build(); + } + + @Test public void it_rejects_files_without_certificates() throws IOException { Path certFile = securityFolder.resolve("foo.pem"); Element clusterElem = DomBuilderTest.parse( @@ -189,6 +227,9 @@ public class CloudDataPlaneFilterTest extends ContainerModelBuilderTestBase { .properties( new TestProperties() .setEndpointCertificateSecrets(Optional.of(new EndpointCertificateSecrets("CERT", "KEY"))) + .setDataplaneTokens(List.of(new DataplaneToken("my-token", List.of( + new DataplaneToken.Version("myfingerprint1", "myaccesshash1"), + new DataplaneToken.Version("myfingerprint2", "myaccesshash2"))))) .setHostedVespa(true)) .zone(new Zone(SystemName.PublicCd, Environment.dev, RegionName.defaultName())) .build(); diff --git a/config-model/src/test/schema-test-files/services.xml b/config-model/src/test/schema-test-files/services.xml index 8806a4e082a..e5cb7e8ef54 100644 --- a/config-model/src/test/schema-test-files/services.xml +++ b/config-model/src/test/schema-test-files/services.xml @@ -218,6 +218,13 @@ <certificate file="security/file1.pem" /> <certificate file="security/file2.pem" /> </client> + <client id="client3" permissions="read"> + <token id="my-token-1" /> + <token id="my-token-2" /> + </client> + <client id="client4" permissions="write"> + <token id="my-token-3" /> + </client> </clients> <document-processing> |