diff options
16 files changed, 143 insertions, 40 deletions
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 2deaf81d338..efa5ee01506 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 @@ -13,6 +13,7 @@ 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.time.Instant; import java.util.Collection; import java.util.List; @@ -62,7 +63,9 @@ class CloudDataPlaneFilter extends Filter implements CloudDataPlaneFilterConfig. .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())) + .checkAccessHashes(token.versions().stream().map(DataplaneToken.Version::checkAccessHash).toList()) + .expirations(token.versions().stream().map(v -> v.expiration().map(Instant::toString).orElse("<none>")).toList())) .toList(); } + } 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 e11eec1ffd7..02ff7b8a03f 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 @@ -35,6 +35,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.security.KeyPair; import java.security.cert.X509Certificate; +import java.time.Duration; import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.Collection; @@ -147,14 +148,15 @@ public class CloudDataPlaneFilterTest extends ContainerModelBuilderTestBase { assertEquals(List.of("read"), tokenClient.permissions()); assertTrue(tokenClient.certificates().isEmpty()); var expectedTokenCfg = tokenConfig( - "my-token", List.of("myfingerprint1", "myfingerprint2"), List.of("myaccesshash1", "myaccesshash2")); + "my-token", List.of("myfingerprint1", "myfingerprint2"), List.of("myaccesshash1", "myaccesshash2"), + List.of("<none>", "2243-10-17T00:00:00Z")); assertEquals(List.of(expectedTokenCfg), tokenClient.tokens()); } private static CloudDataPlaneFilterConfig.Clients.Tokens tokenConfig( - String id, Collection<String> fingerprints, Collection<String> accessCheckHashes) { + String id, Collection<String> fingerprints, Collection<String> accessCheckHashes, Collection<String> expirations) { return new CloudDataPlaneFilterConfig.Clients.Tokens.Builder() - .id(id).fingerprints(fingerprints).checkAccessHashes(accessCheckHashes).build(); + .id(id).fingerprints(fingerprints).checkAccessHashes(accessCheckHashes).expirations(expirations).build(); } @Test @@ -230,8 +232,8 @@ public class CloudDataPlaneFilterTest extends ContainerModelBuilderTestBase { 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"))))) + new DataplaneToken.Version("myfingerprint1", "myaccesshash1", Optional.empty()), + new DataplaneToken.Version("myfingerprint2", "myaccesshash2", Optional.of(Instant.EPOCH.plus(Duration.ofDays(100000)))))))) .setHostedVespa(true)) .zone(new Zone(SystemName.PublicCd, Environment.dev, RegionName.defaultName())) .build(); diff --git a/config-provisioning/src/main/java/com/yahoo/config/provision/DataplaneToken.java b/config-provisioning/src/main/java/com/yahoo/config/provision/DataplaneToken.java index 9b0367a4652..546a27a31ed 100644 --- a/config-provisioning/src/main/java/com/yahoo/config/provision/DataplaneToken.java +++ b/config-provisioning/src/main/java/com/yahoo/config/provision/DataplaneToken.java @@ -1,7 +1,9 @@ // Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. package com.yahoo.config.provision; +import java.time.Instant; import java.util.List; +import java.util.Optional; /** * Id, fingerprints and check access hashes of a data plane token @@ -10,6 +12,6 @@ import java.util.List; */ public record DataplaneToken(String tokenId, List<Version> versions) { - public record Version(String fingerprint, String checkAccessHash){ + public record Version(String fingerprint, String checkAccessHash, Optional<Instant> expiration) { } } diff --git a/configdefinitions/src/vespa/cloud-data-plane-filter.def b/configdefinitions/src/vespa/cloud-data-plane-filter.def index fbad95e2c2a..d73c5a49c81 100644 --- a/configdefinitions/src/vespa/cloud-data-plane-filter.def +++ b/configdefinitions/src/vespa/cloud-data-plane-filter.def @@ -9,3 +9,4 @@ clients[].certificates[] string clients[].tokens[].id string clients[].tokens[].fingerprints[] string clients[].tokens[].checkAccessHashes[] string +clients[].tokens[].expirations[] string diff --git a/configserver/src/main/java/com/yahoo/vespa/config/server/tenant/DataplaneTokenSerializer.java b/configserver/src/main/java/com/yahoo/vespa/config/server/tenant/DataplaneTokenSerializer.java index 27012ae69fe..a8dc48e6c14 100644 --- a/configserver/src/main/java/com/yahoo/vespa/config/server/tenant/DataplaneTokenSerializer.java +++ b/configserver/src/main/java/com/yahoo/vespa/config/server/tenant/DataplaneTokenSerializer.java @@ -7,7 +7,9 @@ import com.yahoo.slime.Inspector; import com.yahoo.slime.Slime; import com.yahoo.slime.SlimeUtils; +import java.time.Instant; import java.util.List; +import java.util.Optional; /** * Serialize/deserialize dataplane tokens @@ -20,6 +22,7 @@ public class DataplaneTokenSerializer { private static final String VERSIONS_FIELD = "versions"; private static final String FINGERPRINT_FIELD = "fingerPrint"; private static final String CHECKACCESSHASH_FIELD = "checkAccessHash"; + private static final String EXPIRATION_FIELD = "expiration"; private DataplaneTokenSerializer() {} @@ -39,9 +42,11 @@ public class DataplaneTokenSerializer { } private static DataplaneToken.Version tokenValue(Inspector inspector) { + String expirationStr = inspector.field(EXPIRATION_FIELD).asString(); return new DataplaneToken.Version( inspector.field(FINGERPRINT_FIELD).asString(), - inspector.field(CHECKACCESSHASH_FIELD).asString()); + inspector.field(CHECKACCESSHASH_FIELD).asString(), + expirationStr.equals("<none>") ? Optional.empty() : Optional.of(Instant.parse(expirationStr))); } public static Slime toSlime(List<DataplaneToken> dataplaneTokens) { @@ -55,6 +60,7 @@ public class DataplaneTokenSerializer { Cursor val = versions.addObject(); val.setString(FINGERPRINT_FIELD, v.fingerprint()); val.setString(CHECKACCESSHASH_FIELD, v.checkAccessHash()); + val.setString(EXPIRATION_FIELD, v.expiration().map(Instant::toString).orElse("<none>")); }); } return slime; diff --git a/configserver/src/test/java/com/yahoo/vespa/config/server/session/SessionPreparerTest.java b/configserver/src/test/java/com/yahoo/vespa/config/server/session/SessionPreparerTest.java index f7b2d4119a7..52d5ba16562 100644 --- a/configserver/src/test/java/com/yahoo/vespa/config/server/session/SessionPreparerTest.java +++ b/configserver/src/test/java/com/yahoo/vespa/config/server/session/SessionPreparerTest.java @@ -366,7 +366,7 @@ public class SessionPreparerTest { TestModelFactory modelFactory = new TestModelFactory(version123); preparer = createPreparer(new ModelFactoryRegistry(List.of(modelFactory)), HostProvisionerProvider.empty()); ApplicationId applicationId = applicationId("test"); - List<DataplaneToken> expected = List.of(new DataplaneToken("id", List.of(new DataplaneToken.Version("f1", "ch1")))); + List<DataplaneToken> expected = List.of(new DataplaneToken("id", List.of(new DataplaneToken.Version("f1", "ch1", Optional.empty())))); PrepareParams params = new PrepareParams.Builder().applicationId(applicationId) .dataplaneTokens(expected) .build(); diff --git a/configserver/src/test/java/com/yahoo/vespa/config/server/tenant/DataplaneTokenSerializerTest.java b/configserver/src/test/java/com/yahoo/vespa/config/server/tenant/DataplaneTokenSerializerTest.java index 505abcb5598..59e98742777 100644 --- a/configserver/src/test/java/com/yahoo/vespa/config/server/tenant/DataplaneTokenSerializerTest.java +++ b/configserver/src/test/java/com/yahoo/vespa/config/server/tenant/DataplaneTokenSerializerTest.java @@ -5,7 +5,10 @@ import com.yahoo.config.provision.DataplaneToken; import com.yahoo.slime.Slime; import org.junit.Test; +import java.time.Instant; +import java.time.temporal.ChronoUnit; import java.util.List; +import java.util.Optional; import static org.junit.Assert.assertEquals; @@ -18,10 +21,10 @@ public class DataplaneTokenSerializerTest { public void testSerialization() { List<DataplaneToken> tokens = List.of( new DataplaneToken("id1", - List.of(new DataplaneToken.Version("id1_fingerPrint1", "id1_checkaccesshash1"))), + List.of(new DataplaneToken.Version("id1_fingerPrint1", "id1_checkaccesshash1", Optional.empty()))), new DataplaneToken("id2", - List.of(new DataplaneToken.Version("id2_fingerPrint1", "id2_checkaccesshash1"), - new DataplaneToken.Version("id3_fingerPrint1", "id3_checkaccesshash1")))); + List.of(new DataplaneToken.Version("id2_fingerPrint1", "id2_checkaccesshash1", Optional.of(Instant.EPOCH)), + new DataplaneToken.Version("id3_fingerPrint1", "id3_checkaccesshash1", Optional.of(Instant.EPOCH.plus(20000, ChronoUnit.DAYS)))))); Slime slime = DataplaneTokenSerializer.toSlime(tokens); List<DataplaneToken> deserialized = DataplaneTokenSerializer.fromSlime(slime.get()); assertEquals(tokens, deserialized); diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/dataplanetoken/DataplaneToken.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/dataplanetoken/DataplaneToken.java index f0cc87df1fe..76df5ce13dd 100644 --- a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/dataplanetoken/DataplaneToken.java +++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/dataplanetoken/DataplaneToken.java @@ -1,6 +1,9 @@ // Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. package com.yahoo.vespa.hosted.controller.api.integration.dataplanetoken; +import java.time.Instant; +import java.util.Optional; + /** * Represents a generated data plane token. * @@ -8,5 +11,5 @@ package com.yahoo.vespa.hosted.controller.api.integration.dataplanetoken; * * @author mortent */ -public record DataplaneToken(TokenId tokenId, FingerPrint fingerPrint, String tokenValue) { +public record DataplaneToken(TokenId tokenId, FingerPrint fingerPrint, String tokenValue, Optional<Instant> expiration) { } diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/dataplanetoken/DataplaneTokenVersions.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/dataplanetoken/DataplaneTokenVersions.java index 618bfbc8a41..1ce558bd84e 100644 --- a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/dataplanetoken/DataplaneTokenVersions.java +++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/dataplanetoken/DataplaneTokenVersions.java @@ -3,6 +3,7 @@ package com.yahoo.vespa.hosted.controller.api.integration.dataplanetoken; import java.time.Instant; import java.util.List; +import java.util.Optional; /** * List of dataplane token versions of a token id. @@ -10,6 +11,7 @@ import java.util.List; * @author mortent */ public record DataplaneTokenVersions(TokenId tokenId, List<Version> tokenVersions) { - public record Version(FingerPrint fingerPrint, String checkAccessHash, Instant creationTime, String author) { + public record Version(FingerPrint fingerPrint, String checkAccessHash, Instant creationTime, + Optional<Instant> expiration, String author) { } } diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/DataplaneTokenSerializer.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/DataplaneTokenSerializer.java index 90be14fd349..5df183d9abb 100644 --- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/DataplaneTokenSerializer.java +++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/DataplaneTokenSerializer.java @@ -10,6 +10,7 @@ import com.yahoo.vespa.hosted.controller.api.integration.dataplanetoken.TokenId; import java.time.Instant; import java.util.List; +import java.util.Optional; /** * @author mortent @@ -23,6 +24,7 @@ public class DataplaneTokenSerializer { private static final String checkAccessHashField = "checkAccessHash"; private static final String creationTimeField = "creationTime"; private static final String authorField = "author"; + private static final String expirationField = "expiration"; public static Slime toSlime(List<DataplaneTokenVersions> dataplaneTokenVersions) { Slime slime = new Slime(); @@ -39,6 +41,7 @@ public class DataplaneTokenSerializer { versionCursor.setLong(creationTimeField, version.creationTime().toEpochMilli()); versionCursor.setString(creationTimeField, version.creationTime().toString()); versionCursor.setString(authorField, version.author()); + versionCursor.setString(expirationField, version.expiration().map(Instant::toString).orElse("<none>")); }); }); return slime; @@ -55,7 +58,9 @@ public class DataplaneTokenSerializer { String checkAccessHash = versionCursor.field(checkAccessHashField).asString(); Instant creationTime = SlimeUtils.instant(versionCursor.field(creationTimeField)); String author = versionCursor.field(authorField).asString(); - return new DataplaneTokenVersions.Version(fingerPrint, checkAccessHash, creationTime, author); + String expirationStr = versionCursor.field(expirationField).asString(); + Optional<Instant> expiration = expirationStr.equals("<none>") ? Optional.empty() : Optional.of(Instant.parse(expirationStr)); + return new DataplaneTokenVersions.Version(fingerPrint, checkAccessHash, creationTime, expiration, author); }) .toList(); return new DataplaneTokenVersions(id, versions); diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationApiHandler.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationApiHandler.java index daa64ddb07f..99ad75d0ec4 100644 --- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationApiHandler.java +++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationApiHandler.java @@ -111,6 +111,7 @@ import com.yahoo.vespa.hosted.controller.notification.Notification; import com.yahoo.vespa.hosted.controller.notification.NotificationSource; import com.yahoo.vespa.hosted.controller.persistence.SupportAccessSerializer; import com.yahoo.vespa.hosted.controller.restapi.ErrorResponses; +import com.yahoo.vespa.hosted.controller.restapi.dataplanetoken.DataplaneTokenService; import com.yahoo.vespa.hosted.controller.routing.RoutingStatus; import com.yahoo.vespa.hosted.controller.routing.context.DeploymentRoutingContext; import com.yahoo.vespa.hosted.controller.routing.rotation.RotationId; @@ -985,12 +986,17 @@ public class ApplicationApiHandler extends AuditLoggingRequestHandler { private HttpResponse generateToken(String tenant, String tokenid, HttpRequest request) { - DataplaneToken token = controller.dataplaneTokenService().generateToken(TenantName.from(tenant), TokenId.of(tokenid), request.getJDiscRequest().getUserPrincipal()); + // 'expiration=PT0S' for no expiration, no 'expiration' for default TTL. + Duration expiration = Optional.ofNullable(request.getProperty("expiration")) + .map(Duration::parse).orElse(DataplaneTokenService.DEFAULT_TTL); + DataplaneToken token = controller.dataplaneTokenService().generateToken( + TenantName.from(tenant), TokenId.of(tokenid), expiration, request.getJDiscRequest().getUserPrincipal()); Slime slime = new Slime(); Cursor tokenObject = slime.setObject(); tokenObject.setString("id", token.tokenId().value()); tokenObject.setString("token", token.tokenValue()); tokenObject.setString("fingerprint", token.fingerPrint().value()); + tokenObject.setString("expiration", token.expiration().map(Instant::toString).orElse("<none>")); return new SlimeJsonResponse(slime); } diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/dataplanetoken/DataplaneTokenService.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/dataplanetoken/DataplaneTokenService.java index b3e5f663317..32872a01bce 100644 --- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/dataplanetoken/DataplaneTokenService.java +++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/dataplanetoken/DataplaneTokenService.java @@ -15,6 +15,8 @@ import com.yahoo.vespa.hosted.controller.api.integration.dataplanetoken.TokenId; import com.yahoo.vespa.hosted.controller.persistence.CuratorDb; import java.security.Principal; +import java.time.Duration; +import java.time.Instant; import java.util.List; import java.util.Objects; import java.util.Optional; @@ -30,6 +32,7 @@ public class DataplaneTokenService { private static final String TOKEN_PREFIX = "vespa_cloud_"; private static final int TOKEN_BYTES = 32; private static final int CHECK_HASH_BYTES = 32; + public static final Duration DEFAULT_TTL = Duration.ofDays(30); private final Controller controller; @@ -51,10 +54,12 @@ public class DataplaneTokenService { * * @param tenantName name of the tenant to connect the token to * @param tokenId The user generated name/id of the token + * @param ttl The time to live of the token. Use {@link Duration#ZERO} for no TTL. * @param principal The principal making the request * @return a DataplaneToken containing the secret generated token */ - public DataplaneToken generateToken(TenantName tenantName, TokenId tokenId, Principal principal) { + public DataplaneToken generateToken(TenantName tenantName, TokenId tokenId, Duration ttl, Principal principal) { + Optional<Instant> expiration = ttl.isZero() ? Optional.empty() : Optional.ofNullable(controller.clock().instant().plus(ttl)); TokenDomain tokenDomain = TokenDomain.of("Vespa Cloud tenant data plane:%s".formatted(tenantName.value())); Token token = TokenGenerator.generateToken(tokenDomain, TOKEN_PREFIX, TOKEN_BYTES); TokenCheckHash checkHash = TokenCheckHash.of(token, CHECK_HASH_BYTES); @@ -62,6 +67,7 @@ public class DataplaneTokenService { FingerPrint.of(token.fingerprint().toDelimitedHexString()), checkHash.toHexString(), controller.clock().instant(), + expiration, principal.getName()); CuratorDb curator = controller.curator(); @@ -85,7 +91,8 @@ public class DataplaneTokenService { curator.writeDataplaneTokens(tenantName, dataplaneTokenVersions); // Return the data plane token including the secret token. - return new DataplaneToken(tokenId, FingerPrint.of(token.fingerprint().toDelimitedHexString()), token.secretTokenString()); + return new DataplaneToken(tokenId, FingerPrint.of(token.fingerprint().toDelimitedHexString()), + token.secretTokenString(), expiration); } } diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/dataplanetoken/DataplaneTokenServiceTest.java b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/dataplanetoken/DataplaneTokenServiceTest.java index 066eecc2c95..9a8e43d1597 100644 --- a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/dataplanetoken/DataplaneTokenServiceTest.java +++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/dataplanetoken/DataplaneTokenServiceTest.java @@ -12,6 +12,7 @@ import com.yahoo.vespa.hosted.controller.api.role.SimplePrincipal; import org.junit.jupiter.api.Test; import java.security.Principal; +import java.time.Duration; import java.util.Collection; import java.util.List; @@ -29,15 +30,16 @@ public class DataplaneTokenServiceTest { @Test void generates_and_persists_token() { - DataplaneToken dataplaneToken = dataplaneTokenService.generateToken(tenantName, tokenId, principal); + DataplaneToken dataplaneToken = dataplaneTokenService.generateToken(tenantName, tokenId, Duration.ofDays(100), principal); List<DataplaneTokenVersions> dataplaneTokenVersions = dataplaneTokenService.listTokens(tenantName); assertEquals(dataplaneToken.fingerPrint(), dataplaneTokenVersions.get(0).tokenVersions().get(0).fingerPrint()); + assertEquals(dataplaneToken.expiration(), dataplaneTokenVersions.get(0).tokenVersions().get(0).expiration()); } @Test void generating_new_token_appends() { - DataplaneToken dataplaneToken1 = dataplaneTokenService.generateToken(tenantName, tokenId, principal); - DataplaneToken dataplaneToken2 = dataplaneTokenService.generateToken(tenantName, tokenId, principal); + DataplaneToken dataplaneToken1 = dataplaneTokenService.generateToken(tenantName, tokenId, Duration.ofDays(1), principal); + DataplaneToken dataplaneToken2 = dataplaneTokenService.generateToken(tenantName, tokenId, Duration.ZERO, principal); assertNotEquals(dataplaneToken1.fingerPrint(), dataplaneToken2.fingerPrint()); List<DataplaneTokenVersions> dataplaneTokenVersions = dataplaneTokenService.listTokens(tenantName); @@ -52,8 +54,8 @@ public class DataplaneTokenServiceTest { @Test void delete_last_fingerprint_deletes_token() { - DataplaneToken dataplaneToken1 = dataplaneTokenService.generateToken(tenantName, tokenId, principal); - DataplaneToken dataplaneToken2 = dataplaneTokenService.generateToken(tenantName, tokenId, principal); + DataplaneToken dataplaneToken1 = dataplaneTokenService.generateToken(tenantName, tokenId, Duration.ZERO, principal); + DataplaneToken dataplaneToken2 = dataplaneTokenService.generateToken(tenantName, tokenId, Duration.ZERO, principal); dataplaneTokenService.deleteToken(tenantName, tokenId, dataplaneToken1.fingerPrint()); dataplaneTokenService.deleteToken(tenantName, tokenId, dataplaneToken2.fingerPrint()); assertEquals(List.of(), dataplaneTokenService.listTokens(tenantName)); @@ -61,8 +63,8 @@ public class DataplaneTokenServiceTest { @Test void deleting_nonexistent_fingerprint_throws() { - DataplaneToken dataplaneToken = dataplaneTokenService.generateToken(tenantName, tokenId, principal); - DataplaneToken dataplaneToken2 = dataplaneTokenService.generateToken(tenantName, tokenId, principal); + DataplaneToken dataplaneToken = dataplaneTokenService.generateToken(tenantName, tokenId, Duration.ZERO, principal); + DataplaneToken dataplaneToken2 = dataplaneTokenService.generateToken(tenantName, tokenId, Duration.ZERO, principal); dataplaneTokenService.deleteToken(tenantName, tokenId, dataplaneToken.fingerPrint()); // Token currently contains value of "dataplaneToken2" @@ -72,7 +74,7 @@ public class DataplaneTokenServiceTest { @Test void deleting_nonexistent_token_throws() { - DataplaneToken dataplaneToken = dataplaneTokenService.generateToken(tenantName, tokenId, principal); + DataplaneToken dataplaneToken = dataplaneTokenService.generateToken(tenantName, tokenId, Duration.ZERO, principal); dataplaneTokenService.deleteToken(tenantName, tokenId, dataplaneToken.fingerPrint()); // Token is created and deleted above, no longer exists diff --git a/jdisc-security-filters/pom.xml b/jdisc-security-filters/pom.xml index 652b864747d..3440f9089d7 100644 --- a/jdisc-security-filters/pom.xml +++ b/jdisc-security-filters/pom.xml @@ -63,6 +63,12 @@ <artifactId>jetty-util</artifactId> <scope>test</scope> </dependency> + <dependency> + <groupId>com.yahoo.vespa</groupId> + <artifactId>testutil</artifactId> + <version>${project.version}</version> + <scope>test</scope> + </dependency> </dependencies> <build> 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 index 96602fcd899..554c1d924a2 100644 --- 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 @@ -20,6 +20,8 @@ import com.yahoo.security.token.TokenFingerprint; import java.security.Principal; import java.security.cert.X509Certificate; +import java.time.Clock; +import java.time.Instant; import java.util.ArrayList; import java.util.EnumSet; import java.util.HashMap; @@ -53,21 +55,23 @@ public class CloudDataPlaneFilter extends JsonSecurityRequestFilterBase { private final boolean legacyMode; private final List<Client> allowedClients; private final TokenDomain tokenDomain; + private final Clock clock; @Inject public CloudDataPlaneFilter(CloudDataPlaneFilterConfig cfg, ComponentRegistry<DataplaneProxyCredentials> optionalReverseProxy) { - this(cfg, reverseProxyCert(optionalReverseProxy).orElse(null)); + this(cfg, reverseProxyCert(optionalReverseProxy).orElse(null), Clock.systemUTC()); } - CloudDataPlaneFilter(CloudDataPlaneFilterConfig cfg, X509Certificate reverseProxyCert) { + CloudDataPlaneFilter(CloudDataPlaneFilterConfig cfg, X509Certificate reverseProxyCert, Clock clock) { this.legacyMode = cfg.legacyMode(); this.tokenDomain = TokenDomain.of(cfg.tokenContext()); + this.clock = clock; if (legacyMode) { allowedClients = List.of(); log.fine(() -> "Legacy mode enabled"); } else { - allowedClients = parseClients(cfg, reverseProxyCert); + allowedClients = parseClients(cfg, reverseProxyCert, clock); } } @@ -76,7 +80,8 @@ public class CloudDataPlaneFilter extends JsonSecurityRequestFilterBase { return optionalReverseProxy.allComponents().stream().findAny().map(DataplaneProxyCredentials::certificate); } - private static List<Client> parseClients(CloudDataPlaneFilterConfig cfg, X509Certificate reverseProxyCert) { + private static List<Client> parseClients(CloudDataPlaneFilterConfig cfg, X509Certificate reverseProxyCert, Clock clock) { + var now = clock.instant(); Set<String> ids = new HashSet<>(); List<Client> clients = new ArrayList<>(cfg.clients().size()); boolean hasClientRequiringCertificate = false; @@ -112,8 +117,14 @@ public class CloudDataPlaneFilter extends JsonSecurityRequestFilterBase { for (var token : c.tokens()) { for (int version = 0; version < token.checkAccessHashes().size(); version++) { var tokenVersion = TokenVersion.of( - token.id(), token.fingerprints().get(version), token.checkAccessHashes().get(version)); - tokens.put(tokenVersion.accessHash(), tokenVersion); + token.id(), token.fingerprints().get(version), token.checkAccessHashes().get(version), + token.expirations().get(version)); + var expiration = tokenVersion.expiration().orElse(null); + if (expiration != null && now.isAfter(expiration)) + log.fine(() -> "Ignoring expired version %s of token '%s' (expiration=%s)".formatted( + tokenVersion.fingerprint(), tokenVersion.id(), expiration)); + else + tokens.put(tokenVersion.accessHash(), tokenVersion); } } // Add reverse proxy certificate as required certificate for client definition @@ -128,6 +139,7 @@ public class CloudDataPlaneFilter extends JsonSecurityRequestFilterBase { @Override protected Optional<ErrorResponse> filter(DiscFilterRequest req) { + var now = clock.instant(); var certs = req.getClientCertificateChain(); log.fine(() -> "Certificate chain contains %d elements".formatted(certs.size())); if (certs.isEmpty()) { @@ -164,6 +176,8 @@ public class CloudDataPlaneFilter extends JsonSecurityRequestFilterBase { if (requestTokenHash == null) continue; var matchedToken = c.tokens().get(requestTokenHash); if (matchedToken == null) continue; + var expiration = matchedToken.expiration().orElse(null); + if (expiration != null && now.isAfter(expiration)) continue; matchedTokens.add(matchedToken); } clientIds.add(c.id()); @@ -178,6 +192,7 @@ public class CloudDataPlaneFilter extends JsonSecurityRequestFilterBase { if (matchedToken != null) { addAccessLogEntry(req, "token.id", matchedToken.id()); addAccessLogEntry(req, "token.hash", matchedToken.fingerprint().toDelimitedHexString()); + addAccessLogEntry(req, "token.exp", matchedToken.expiration().map(Instant::toString).orElse("<none>")); } log.fine(() -> "Client with ids=%s, permissions=%s" .formatted(clientIds, permissions.stream().map(Permission::asString).toList())); @@ -225,9 +240,10 @@ public class CloudDataPlaneFilter extends JsonSecurityRequestFilterBase { } } - private record TokenVersion(String id, TokenFingerprint fingerprint, TokenCheckHash accessHash) { - static TokenVersion of(String id, String fingerprint, String accessHash) { - return new TokenVersion(id, TokenFingerprint.ofHex(fingerprint), TokenCheckHash.ofHex(accessHash)); + private record TokenVersion(String id, TokenFingerprint fingerprint, TokenCheckHash accessHash, Optional<Instant> expiration) { + static TokenVersion of(String id, String fingerprint, String accessHash, String expiration) { + return new TokenVersion(id, TokenFingerprint.ofHex(fingerprint), TokenCheckHash.ofHex(accessHash), + expiration.equals("<none>") ? Optional.empty() : Optional.of(Instant.parse(expiration))); } } 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 index d05baccc069..d9daf8b6f46 100644 --- 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 @@ -17,11 +17,15 @@ import com.yahoo.security.token.Token; import com.yahoo.security.token.TokenCheckHash; import com.yahoo.security.token.TokenDomain; import com.yahoo.security.token.TokenGenerator; +import com.yahoo.test.ManualClock; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import javax.security.auth.x500.X500Principal; import java.math.BigInteger; import java.security.cert.X509Certificate; +import java.time.Duration; +import java.time.Instant; import java.util.List; import java.util.Set; @@ -52,11 +56,16 @@ class CloudDataPlaneFilterTest { private static final String TOKEN_SEARCH_CLIENT = "token-search-client"; private static final String TOKEN_CONTEXT = "my-token-context"; private static final String TOKEN_ID = "my-token-id"; + private static final Instant TOKEN_EXPIRATION = EPOCH.plus(Duration.ofDays(1)); private static final Token VALID_TOKEN = TokenGenerator.generateToken(TokenDomain.of(TOKEN_CONTEXT), "vespa_token_", CHECK_HASH_BYTES); private static final Token UNKNOWN_TOKEN = TokenGenerator.generateToken(TokenDomain.of(TOKEN_CONTEXT), "vespa_token_", CHECK_HASH_BYTES); + private ManualClock clock; + + @BeforeEach void resetClock() { clock = new ManualClock(EPOCH); } + @Test void accepts_any_trusted_client_certificate_in_legacy_mode() { var req = FilterTestUtils.newRequestBuilder().withClientCertificate(LEGACY_CLIENT_CERT).build(); @@ -150,6 +159,7 @@ class CloudDataPlaneFilterTest { assertEquals(new ClientPrincipal(Set.of(TOKEN_SEARCH_CLIENT), Set.of(READ)), req.getUserPrincipal()); assertEquals(TOKEN_ID, entry.getKeyValues().get("token.id").get(0)); assertEquals(VALID_TOKEN.fingerprint().toDelimitedHexString(), entry.getKeyValues().get("token.hash").get(0)); + assertEquals(TOKEN_EXPIRATION.toString(), entry.getKeyValues().get("token.exp").get(0)); } @Test @@ -228,13 +238,40 @@ class CloudDataPlaneFilterTest { assertEquals(new ClientPrincipal(Set.of(FEED_CLIENT_ID), Set.of(WRITE)), req.getUserPrincipal()); } - private static CloudDataPlaneFilter newFilterWithLegacyMode() { + @Test + void fails_for_expired_token() { + var entry = new AccessLogEntry(); + var req = FilterTestUtils.newRequestBuilder() + .withMethod(Method.GET) + .withAccessLogEntry(entry) + .withClientCertificate(REVERSE_PROXY_CERT) + .withHeader("Authorization", "Bearer " + VALID_TOKEN.secretTokenString()) + .build(); + var filter = newFilterWithClientsConfig(); + + var responseHandler = new MockResponseHandler(); + filter.filter(req, responseHandler); + assertNull(responseHandler.getResponse()); + + clock.advance(Duration.ofDays(1)); + responseHandler = new MockResponseHandler(); + filter.filter(req, responseHandler); + assertNull(responseHandler.getResponse()); + + clock.advance(Duration.ofMillis(1)); + responseHandler = new MockResponseHandler(); + filter.filter(req, responseHandler); + assertNotNull(responseHandler.getResponse()); + assertEquals(FORBIDDEN, responseHandler.getResponse().getStatus()); + } + + private CloudDataPlaneFilter newFilterWithLegacyMode() { return new CloudDataPlaneFilter( new CloudDataPlaneFilterConfig.Builder() - .legacyMode(true).build(), (X509Certificate) null); + .legacyMode(true).build(), (X509Certificate) null, clock); } - private static CloudDataPlaneFilter newFilterWithClientsConfig() { + private CloudDataPlaneFilter newFilterWithClientsConfig() { return new CloudDataPlaneFilter( new CloudDataPlaneFilterConfig.Builder() .tokenContext(TOKEN_CONTEXT) @@ -251,11 +288,13 @@ class CloudDataPlaneFilterTest { .tokens(new CloudDataPlaneFilterConfig.Clients.Tokens.Builder() .id(TOKEN_ID) .checkAccessHashes(TokenCheckHash.of(VALID_TOKEN, 32).toHexString()) - .fingerprints(VALID_TOKEN.fingerprint().toDelimitedHexString())) + .fingerprints(VALID_TOKEN.fingerprint().toDelimitedHexString()) + .expirations(TOKEN_EXPIRATION.toString())) .permissions(READ.asString()) .id(TOKEN_SEARCH_CLIENT))) .build(), - REVERSE_PROXY_CERT); + REVERSE_PROXY_CERT, + clock); } private static X509Certificate certificate(String name) { |