summaryrefslogtreecommitdiffstats
path: root/controller-server
diff options
context:
space:
mode:
authorMorten Tokle <mortent@yahooinc.com>2023-06-12 22:45:43 +0200
committerMorten Tokle <mortent@yahooinc.com>2023-06-12 22:45:43 +0200
commit362ddb0749608a5ace2be1caa5507ca9d3895eaf (patch)
treeeff7d386e26d0d293f123a16139d10b19e84ebd7 /controller-server
parent5f25e0ba346c04ccc27c60cc410c0ed2fdb6b06b (diff)
API to generate/list/delete dataplane tokens
Diffstat (limited to 'controller-server')
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/Controller.java9
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/CuratorDb.java16
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/DataplaneTokenSerializer.java65
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationApiHandler.java42
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/dataplanetoken/DataplaneTokenService.java121
-rw-r--r--controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationApiCloudTest.java36
-rw-r--r--controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/dataplanetoken/DataplaneTokenServiceTest.java82
7 files changed, 369 insertions, 2 deletions
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/Controller.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/Controller.java
index fb437c3258b..287cfaa41b8 100644
--- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/Controller.java
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/Controller.java
@@ -28,6 +28,7 @@ import com.yahoo.vespa.hosted.controller.notification.Notifier;
import com.yahoo.vespa.hosted.controller.persistence.CuratorDb;
import com.yahoo.vespa.hosted.controller.persistence.JobControlFlags;
import com.yahoo.vespa.hosted.controller.security.AccessControl;
+import com.yahoo.vespa.hosted.controller.restapi.dataplanetoken.DataplaneTokenService;
import com.yahoo.vespa.hosted.controller.support.access.SupportAccessControl;
import com.yahoo.vespa.hosted.controller.versions.OsVersion;
import com.yahoo.vespa.hosted.controller.versions.OsVersionStatus;
@@ -40,7 +41,6 @@ import com.yahoo.yolean.concurrent.Sleeper;
import java.time.Clock;
import java.time.Instant;
import java.util.LinkedHashMap;
-import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
@@ -51,7 +51,6 @@ import java.util.function.Predicate;
import java.util.logging.Logger;
import java.util.stream.Collectors;
-import static java.util.stream.Collectors.toList;
import static java.util.stream.Collectors.toSet;
/**
@@ -91,6 +90,7 @@ public class Controller extends AbstractComponent {
private final SupportAccessControl supportAccessControl;
private final Notifier notifier;
private final MailVerifier mailVerifier;
+ private final DataplaneTokenService dataplaneTokenService;
/**
* Creates a controller
@@ -132,6 +132,7 @@ public class Controller extends AbstractComponent {
notificationsDb = new NotificationsDb(this);
supportAccessControl = new SupportAccessControl(this);
mailVerifier = new MailVerifier(serviceRegistry.zoneRegistry().dashboardUrl(), tenantController, serviceRegistry.mailer(), curator, clock);
+ dataplaneTokenService = new DataplaneTokenService(this);
// Record the version of this controller
curator().writeControllerVersion(this.hostname(), serviceRegistry.controllerVersion());
@@ -357,4 +358,8 @@ public class Controller extends AbstractComponent {
public MailVerifier mailVerifier() {
return mailVerifier;
}
+
+ public DataplaneTokenService dataplaneTokenService() {
+ return dataplaneTokenService;
+ }
}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/CuratorDb.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/CuratorDb.java
index d4e6d7af4b4..ce7129b7b8c 100644
--- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/CuratorDb.java
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/CuratorDb.java
@@ -21,6 +21,7 @@ import com.yahoo.vespa.hosted.controller.api.identifiers.ControllerVersion;
import com.yahoo.vespa.hosted.controller.api.identifiers.DeploymentId;
import com.yahoo.vespa.hosted.controller.api.integration.archive.ArchiveBuckets;
import com.yahoo.vespa.hosted.controller.api.integration.certificates.EndpointCertificateMetadata;
+import com.yahoo.vespa.hosted.controller.api.integration.dataplanetoken.DataplaneTokenVersions;
import com.yahoo.vespa.hosted.controller.api.integration.deployment.JobType;
import com.yahoo.vespa.hosted.controller.api.integration.deployment.RunId;
import com.yahoo.vespa.hosted.controller.api.integration.dns.VpcEndpointService.DnsChallenge;
@@ -101,6 +102,7 @@ public class CuratorDb {
private static final Path notificationsRoot = root.append("notifications");
private static final Path supportAccessRoot = root.append("supportAccess");
private static final Path mailVerificationRoot = root.append("mailVerification");
+ private static final Path dataPlaneTokenRoot = root.append("dataplaneTokens");
private final NodeVersionSerializer nodeVersionSerializer = new NodeVersionSerializer();
private final VersionStatusSerializer versionStatusSerializer = new VersionStatusSerializer(nodeVersionSerializer);
@@ -727,6 +729,16 @@ public class CuratorDb {
curator.delete(mailVerificationPath(pendingMailVerification.getVerificationCode()));
}
+ // -------------- Date plane tokens ---------------------------------------
+
+ public void writeDataplaneTokens(TenantName tenantName, List<DataplaneTokenVersions> dataplaneTokenVersions) {
+ curator.set(dataplaneTokenPath(tenantName), asJson(DataplaneTokenSerializer.toSlime(dataplaneTokenVersions)));
+ }
+
+ public List<DataplaneTokenVersions> readDataplaneTokens(TenantName tenantName) {
+ return readSlime(dataplaneTokenPath(tenantName)).map(DataplaneTokenSerializer::fromSlime).orElse(List.of());
+ }
+
// -------------- Paths ---------------------------------------------------
private static Path upgradesPerMinutePath() {
@@ -831,4 +843,8 @@ public class CuratorDb {
return mailVerificationRoot.append(verificationCode);
}
+ private static Path dataplaneTokenPath(TenantName tenantName) {
+ return dataPlaneTokenRoot.append(tenantName.value());
+ }
+
}
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
new file mode 100644
index 00000000000..90be14fd349
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/DataplaneTokenSerializer.java
@@ -0,0 +1,65 @@
+// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.vespa.hosted.controller.persistence;
+
+import com.yahoo.slime.Cursor;
+import com.yahoo.slime.Slime;
+import com.yahoo.slime.SlimeUtils;
+import com.yahoo.vespa.hosted.controller.api.integration.dataplanetoken.DataplaneTokenVersions;
+import com.yahoo.vespa.hosted.controller.api.integration.dataplanetoken.FingerPrint;
+import com.yahoo.vespa.hosted.controller.api.integration.dataplanetoken.TokenId;
+
+import java.time.Instant;
+import java.util.List;
+
+/**
+ * @author mortent
+ */
+public class DataplaneTokenSerializer {
+
+ private static final String dataplaneTokenField = "dataplaneToken";
+ private static final String idField = "id";
+ private static final String tokenVersionsField = "tokenVersions";
+ private static final String fingerPrintField = "fingerPrint";
+ private static final String checkAccessHashField = "checkAccessHash";
+ private static final String creationTimeField = "creationTime";
+ private static final String authorField = "author";
+
+ public static Slime toSlime(List<DataplaneTokenVersions> dataplaneTokenVersions) {
+ Slime slime = new Slime();
+ Cursor cursor = slime.setObject();
+ Cursor array = cursor.setArray(dataplaneTokenField);
+ dataplaneTokenVersions.forEach(tokenMetadata -> {
+ Cursor tokenCursor = array.addObject();
+ tokenCursor.setString(idField, tokenMetadata.tokenId().value());
+ Cursor versionArray = tokenCursor.setArray(tokenVersionsField);
+ tokenMetadata.tokenVersions().forEach(version -> {
+ Cursor versionCursor = versionArray.addObject();
+ versionCursor.setString(fingerPrintField, version.fingerPrint().value());
+ versionCursor.setString(checkAccessHashField, version.checkAccessHash());
+ versionCursor.setLong(creationTimeField, version.creationTime().toEpochMilli());
+ versionCursor.setString(creationTimeField, version.creationTime().toString());
+ versionCursor.setString(authorField, version.author());
+ });
+ });
+ return slime;
+ }
+
+ public static List<DataplaneTokenVersions> fromSlime(Slime slime) {
+ Cursor cursor = slime.get();
+ return SlimeUtils.entriesStream(cursor.field(dataplaneTokenField))
+ .map(entry -> {
+ TokenId id = TokenId.of(entry.field(idField).asString());
+ List<DataplaneTokenVersions.Version> versions = SlimeUtils.entriesStream(entry.field(tokenVersionsField))
+ .map(versionCursor -> {
+ FingerPrint fingerPrint = FingerPrint.of(versionCursor.field(fingerPrintField).asString());
+ 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);
+ })
+ .toList();
+ return new DataplaneTokenVersions(id, versions);
+ })
+ .toList();
+ }
+}
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 cc5438e9ed0..fbe6789db33 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
@@ -73,6 +73,10 @@ import com.yahoo.vespa.hosted.controller.api.integration.configserver.LoadBalanc
import com.yahoo.vespa.hosted.controller.api.integration.configserver.Node;
import com.yahoo.vespa.hosted.controller.api.integration.configserver.NodeFilter;
import com.yahoo.vespa.hosted.controller.api.integration.configserver.NodeRepository;
+import com.yahoo.vespa.hosted.controller.api.integration.dataplanetoken.DataplaneToken;
+import com.yahoo.vespa.hosted.controller.api.integration.dataplanetoken.DataplaneTokenVersions;
+import com.yahoo.vespa.hosted.controller.api.integration.dataplanetoken.FingerPrint;
+import com.yahoo.vespa.hosted.controller.api.integration.dataplanetoken.TokenId;
import com.yahoo.vespa.hosted.controller.api.integration.deployment.ApplicationVersion;
import com.yahoo.vespa.hosted.controller.api.integration.deployment.JobId;
import com.yahoo.vespa.hosted.controller.api.integration.deployment.JobType;
@@ -257,6 +261,7 @@ public class ApplicationApiHandler extends AuditLoggingRequestHandler {
if (path.matches("/application/v4/tenant/{tenant}/info/contacts")) return withCloudTenant(path.get("tenant"), this::tenantInfoContacts);
if (path.matches("/application/v4/tenant/{tenant}/notifications")) return notifications(request, Optional.of(path.get("tenant")), false);
if (path.matches("/application/v4/tenant/{tenant}/secret-store/{name}/validate")) return validateSecretStore(path.get("tenant"), path.get("name"), request);
+ if (path.matches("/application/v4/tenant/{tenant}/token")) return listTokens(path.get("tenant"), request);
if (path.matches("/application/v4/tenant/{tenant}/application")) return applications(path.get("tenant"), Optional.empty(), request);
if (path.matches("/application/v4/tenant/{tenant}/application/{application}")) return application(path.get("tenant"), path.get("application"), request);
if (path.matches("/application/v4/tenant/{tenant}/application/{application}/compile-version")) return compileVersion(path.get("tenant"), path.get("application"), request.getProperty("allowMajor"));
@@ -327,6 +332,7 @@ public class ApplicationApiHandler extends AuditLoggingRequestHandler {
private HttpResponse handlePOST(Path path, HttpRequest request) {
if (path.matches("/application/v4/tenant/{tenant}")) return createTenant(path.get("tenant"), request);
if (path.matches("/application/v4/tenant/{tenant}/key")) return addDeveloperKey(path.get("tenant"), request);
+ if (path.matches("/application/v4/tenant/{tenant}/token/{tokenid}")) return generateToken(path.get("tenant"), path.get("tokenid"), request);
if (path.matches("/application/v4/tenant/{tenant}/application/{application}")) return createApplication(path.get("tenant"), path.get("application"), request);
if (path.matches("/application/v4/tenant/{tenant}/application/{application}/deploying/platform")) return deployPlatform(path.get("tenant"), path.get("application"), "default", false, request);
if (path.matches("/application/v4/tenant/{tenant}/application/{application}/deploying/pin")) return deployPlatform(path.get("tenant"), path.get("application"), "default", true, request);
@@ -373,6 +379,7 @@ public class ApplicationApiHandler extends AuditLoggingRequestHandler {
if (path.matches("/application/v4/tenant/{tenant}/archive-access/aws")) return removeAwsArchiveAccess(path.get("tenant"));
if (path.matches("/application/v4/tenant/{tenant}/archive-access/gcp")) return removeGcpArchiveAccess(path.get("tenant"));
if (path.matches("/application/v4/tenant/{tenant}/secret-store/{name}")) return deleteSecretStore(path.get("tenant"), path.get("name"), request);
+ if (path.matches("/application/v4/tenant/{tenant}/token/{tokenid}")) return deleteToken(path.get("tenant"), path.get("tokenid"), request);
if (path.matches("/application/v4/tenant/{tenant}/application/{application}")) return deleteApplication(path.get("tenant"), path.get("application"), request);
if (path.matches("/application/v4/tenant/{tenant}/application/{application}/deployment")) return removeAllProdDeployments(path.get("tenant"), path.get("application"));
if (path.matches("/application/v4/tenant/{tenant}/application/{application}/deploying")) return cancelDeploy(path.get("tenant"), path.get("application"), "default", "all");
@@ -903,6 +910,41 @@ public class ApplicationApiHandler extends AuditLoggingRequestHandler {
return new SlimeJsonResponse(slime);
}
+ private HttpResponse listTokens(String tenant, HttpRequest request) {
+ List<DataplaneTokenVersions> dataplaneTokenVersions = controller.dataplaneTokenService().listTokens(TenantName.from(tenant));
+ Slime slime = new Slime();
+ Cursor tokensArray = slime.setObject().setArray("tokens");
+ for (DataplaneTokenVersions token : dataplaneTokenVersions) {
+ Cursor tokenObject = tokensArray.addObject();
+ tokenObject.setString("id", token.tokenId().value());
+ Cursor fingerprintsArray = tokenObject.setArray("fingerprints");
+ for (DataplaneTokenVersions.Version tokenVersion : token.tokenVersions()) {
+ Cursor fingerprintObject = fingerprintsArray.addObject();
+ fingerprintObject.setString("value", tokenVersion.fingerPrint().value());
+ fingerprintObject.setString("created-at", tokenVersion.creationTime().toString());
+ fingerprintObject.setString("author", tokenVersion.author());
+ }
+ }
+ return new SlimeJsonResponse(slime);
+ }
+
+
+ private HttpResponse generateToken(String tenant, String tokenid, HttpRequest request) {
+ DataplaneToken token = controller.dataplaneTokenService().generateToken(TenantName.from(tenant), TokenId.of(tokenid), 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());
+ return new SlimeJsonResponse(slime);
+ }
+
+ private HttpResponse deleteToken(String tenant, String tokenid, HttpRequest request) {
+ String fingerprint = Optional.ofNullable(request.getProperty("fingerprint")).orElseThrow(() -> new IllegalArgumentException("Cannot delete token without fingerprint"));
+ controller.dataplaneTokenService().deleteToken(TenantName.from(tenant), TokenId.of(tokenid), FingerPrint.of(fingerprint));
+ return new MessageResponse("Token version deleted");
+ }
+
private static <T> boolean propertyEquals(HttpRequest request, String property, Function<String, T> mapper, Optional<T> value) {
return Optional.ofNullable(request.getProperty(property))
.map(propertyValue -> value.isPresent() && mapper.apply(propertyValue).equals(value.get()))
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
new file mode 100644
index 00000000000..cef8cf80e77
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/dataplanetoken/DataplaneTokenService.java
@@ -0,0 +1,121 @@
+// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.vespa.hosted.controller.restapi.dataplanetoken;
+
+import com.yahoo.config.provision.TenantName;
+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.transaction.Mutex;
+import com.yahoo.vespa.hosted.controller.Controller;
+import com.yahoo.vespa.hosted.controller.api.integration.dataplanetoken.DataplaneToken;
+import com.yahoo.vespa.hosted.controller.api.integration.dataplanetoken.DataplaneTokenVersions;
+import com.yahoo.vespa.hosted.controller.api.integration.dataplanetoken.FingerPrint;
+import com.yahoo.vespa.hosted.controller.api.integration.dataplanetoken.TokenId;
+import com.yahoo.vespa.hosted.controller.persistence.CuratorDb;
+
+import java.nio.charset.StandardCharsets;
+import java.security.Principal;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.stream.Stream;
+
+/**
+ * Service to list, generate and delete data plane tokens
+ *
+ * @author mortent
+ */
+public class DataplaneTokenService {
+
+ private static final String TOKEN_PREFIX = "vespa_cloud_";
+ private static final byte[] FINGERPRINT_CONTEXT = new byte[0];
+ private static final int TOKEN_BYTES = 16;
+
+
+ private final Controller controller;
+
+ public DataplaneTokenService(Controller controller) {
+ this.controller = controller;
+ }
+
+ /**
+ * List valid tokens for a tenant
+ */
+ public List<DataplaneTokenVersions> listTokens(TenantName tenantName) {
+ return controller.curator().readDataplaneTokens(tenantName);
+ }
+
+ /**
+ * Generates a token using tenant name as the check access context.
+ * Persists the token fingerprint and check access hash, but not the token value
+ *
+ * @param tenantName name of the tenant to connect the token to
+ * @param tokenId The user generated name/id of the token
+ * @param principal The principal making the request
+ * @return a DataplaneToken containing the secret generated token
+ */
+ public DataplaneToken generateToken(TenantName tenantName, TokenId tokenId, Principal principal) {
+ TokenDomain tokenDomain = new TokenDomain(FINGERPRINT_CONTEXT, tenantName.value().getBytes(StandardCharsets.UTF_8));
+ Token token = TokenGenerator.generateToken(tokenDomain, TOKEN_PREFIX, TOKEN_BYTES);
+ TokenCheckHash checkHash = TokenCheckHash.of(token, TOKEN_BYTES);
+ DataplaneTokenVersions.Version newTokenVersion = new DataplaneTokenVersions.Version(
+ FingerPrint.of(token.fingerprint().toDelimitedHexString()),
+ checkHash.toHexString(),
+ controller.clock().instant(),
+ principal.getName());
+
+ CuratorDb curator = controller.curator();
+ try (Mutex lock = curator.lock(tenantName)) {
+ List<DataplaneTokenVersions> dataplaneTokenVersions = curator.readDataplaneTokens(tenantName);
+ Optional<DataplaneTokenVersions> existingToken = dataplaneTokenVersions.stream().filter(t -> Objects.equals(t.tokenId(), tokenId)).findFirst();
+ if (existingToken.isPresent()) {
+ List<DataplaneTokenVersions.Version> versions = existingToken.get().tokenVersions();
+ versions = Stream.concat(
+ versions.stream(),
+ Stream.of(newTokenVersion))
+ .toList();
+ dataplaneTokenVersions = Stream.concat(
+ dataplaneTokenVersions.stream().filter(t -> !Objects.equals(t.tokenId(), tokenId)),
+ Stream.of(new DataplaneTokenVersions(tokenId, versions)))
+ .toList();
+ } else {
+ DataplaneTokenVersions newToken = new DataplaneTokenVersions(tokenId, List.of(newTokenVersion));
+ dataplaneTokenVersions = Stream.concat(dataplaneTokenVersions.stream(), Stream.of(newToken)).toList();
+ }
+ curator.writeDataplaneTokens(tenantName, dataplaneTokenVersions);
+
+ // Return the data plane token including the secret token.
+ return new DataplaneToken(tokenId, FingerPrint.of(token.fingerprint().toDelimitedHexString()), token.secretTokenString());
+ }
+ }
+
+ /**
+ * Deletes the token version identitfied by tokenId and tokenFingerPrint
+ * @throws IllegalArgumentException if the version could not be found
+ */
+ public void deleteToken(TenantName tenantName, TokenId tokenId, FingerPrint tokenFingerprint) {
+ CuratorDb curator = controller.curator();
+ try (Mutex lock = curator.lock(tenantName)) {
+ List<DataplaneTokenVersions> dataplaneTokenVersions = curator.readDataplaneTokens(tenantName);
+ Optional<DataplaneTokenVersions> existingToken = dataplaneTokenVersions.stream().filter(t -> Objects.equals(t.tokenId(), tokenId)).findFirst();
+ if (existingToken.isPresent()) {
+ List<DataplaneTokenVersions.Version> versions = existingToken.get().tokenVersions();
+ versions = versions.stream().filter(v -> !Objects.equals(v.fingerPrint(), tokenFingerprint)).toList();
+ if (versions.isEmpty()) {
+ dataplaneTokenVersions = dataplaneTokenVersions.stream().filter(t -> !Objects.equals(t.tokenId(), tokenId)).toList();
+ } else {
+ boolean fingerPrintExists = existingToken.get().tokenVersions().stream().anyMatch(v -> v.fingerPrint().equals(tokenFingerprint));
+ if (fingerPrintExists) {
+ dataplaneTokenVersions = Stream.concat(dataplaneTokenVersions.stream().filter(t -> !Objects.equals(t.tokenId(), tokenId)), Stream.of(new DataplaneTokenVersions(tokenId, versions))).toList();
+ } else {
+ throw new IllegalArgumentException("Fingerprint does not exist: " + tokenFingerprint);
+ }
+ }
+ curator.writeDataplaneTokens(tenantName, dataplaneTokenVersions);
+ } else {
+ throw new IllegalArgumentException("Token does not exist: " + tokenId);
+ }
+ }
+ }
+}
diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationApiCloudTest.java b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationApiCloudTest.java
index 6012b491fe7..841e46ad881 100644
--- a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationApiCloudTest.java
+++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationApiCloudTest.java
@@ -28,6 +28,7 @@ import com.yahoo.vespa.hosted.controller.security.Auth0Credentials;
import com.yahoo.vespa.hosted.controller.security.CloudTenantSpec;
import com.yahoo.vespa.hosted.controller.security.Credentials;
import com.yahoo.vespa.hosted.controller.tenant.CloudTenant;
+import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -35,12 +36,14 @@ import java.io.File;
import java.util.Collections;
import java.util.Optional;
import java.util.Set;
+import java.util.regex.Pattern;
import static com.yahoo.application.container.handler.Request.Method.DELETE;
import static com.yahoo.application.container.handler.Request.Method.GET;
import static com.yahoo.application.container.handler.Request.Method.POST;
import static com.yahoo.application.container.handler.Request.Method.PUT;
import static com.yahoo.vespa.hosted.controller.restapi.application.ApplicationApiTest.createApplicationSubmissionData;
+import static java.nio.charset.StandardCharsets.UTF_8;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -462,6 +465,39 @@ public class ApplicationApiCloudTest extends ControllerContainerCloudTest {
assertTrue(tester.controller().applications().getApplication(TenantAndApplicationId.from(tenantName, application)).isPresent());
}
+ @Test
+ void dataplane_token_test() {
+ tester.assertResponse(request("/application/v4/tenant/scoober/token", GET)
+ .roles(Role.developer(tenantName)),
+ "{\"tokens\":[]}", 200);
+
+ String regexGenerateToken = "\\{\"id\":\"myTokenId\",\"token\":\"vespa_cloud_.*\",\"fingerprint\":\".*\"}";
+ tester.assertResponse(request("/application/v4/tenant/scoober/token/myTokenId", POST).roles(Role.developer(tenantName)),
+ (response) -> Assertions.assertThat(new String(response.getBody(), UTF_8)).matches(Pattern.compile(regexGenerateToken)),
+ 200);
+
+ String regexListTokens = "\\{\"tokens\":\\[\\{\"id\":\"myTokenId\",\"fingerprints\":\\[\\{\"value\":\".*\",\"created-at\":\".*\",\"author\":\"user@test\"}]}]}";
+ tester.assertResponse(request("/application/v4/tenant/scoober/token", GET)
+ .roles(Role.developer(tenantName)),
+ (response) -> Assertions.assertThat(new String(response.getBody(), UTF_8)).matches(Pattern.compile(regexListTokens)),
+ 200);
+
+ // Rejects invalid tokenIds on create
+ tester.assertResponse(request("/application/v4/tenant/scoober/token/foo+bar", POST).roles(Role.developer(tenantName)),
+ "{\"error-code\":\"BAD_REQUEST\",\"message\":\"tokenId must match '[A-Za-z][A-Za-z0-9_-]{0,59}', but got: 'foo bar'\"}",
+ 400);
+
+ // Rejects invalid tokenIds on delete
+ tester.assertResponse(request("/application/v4/tenant/scoober/token/foo+bar?fingerprint=ab:cd", DELETE).roles(Role.developer(tenantName)),
+ "{\"error-code\":\"BAD_REQUEST\",\"message\":\"tokenId must match '[A-Za-z][A-Za-z0-9_-]{0,59}', but got: 'foo bar'\"}",
+ 400);
+
+ // Rejects invalid fingerprints on delete
+ tester.assertResponse(request("/application/v4/tenant/scoober/token/tokenid?fingerprint=ab:cdef", DELETE).roles(Role.developer(tenantName)),
+ "{\"error-code\":\"BAD_REQUEST\",\"message\":\"fingerPrint must match '([a-f0-9]{2}:)+[a-f0-9]{2}', but got: 'ab:cdef'\"}",
+ 400);
+ }
+
private ApplicationPackageBuilder prodBuilder() {
return new ApplicationPackageBuilder()
.withoutAthenzIdentity()
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
new file mode 100644
index 00000000000..066eecc2c95
--- /dev/null
+++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/dataplanetoken/DataplaneTokenServiceTest.java
@@ -0,0 +1,82 @@
+// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.vespa.hosted.controller.restapi.dataplanetoken;
+
+import com.yahoo.config.provision.SystemName;
+import com.yahoo.config.provision.TenantName;
+import com.yahoo.vespa.hosted.controller.ControllerTester;
+import com.yahoo.vespa.hosted.controller.api.integration.dataplanetoken.DataplaneToken;
+import com.yahoo.vespa.hosted.controller.api.integration.dataplanetoken.DataplaneTokenVersions;
+import com.yahoo.vespa.hosted.controller.api.integration.dataplanetoken.FingerPrint;
+import com.yahoo.vespa.hosted.controller.api.integration.dataplanetoken.TokenId;
+import com.yahoo.vespa.hosted.controller.api.role.SimplePrincipal;
+import org.junit.jupiter.api.Test;
+
+import java.security.Principal;
+import java.util.Collection;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+public class DataplaneTokenServiceTest {
+ private final ControllerTester tester = new ControllerTester(SystemName.Public);
+ private final DataplaneTokenService dataplaneTokenService = new DataplaneTokenService(tester.controller());
+ private final TenantName tenantName = TenantName.from("tenant");
+ Principal principal = new SimplePrincipal("user");
+ private final TokenId tokenId = TokenId.of("myTokenId");
+
+ @Test
+ void generates_and_persists_token() {
+ DataplaneToken dataplaneToken = dataplaneTokenService.generateToken(tenantName, tokenId, principal);
+ List<DataplaneTokenVersions> dataplaneTokenVersions = dataplaneTokenService.listTokens(tenantName);
+ assertEquals(dataplaneToken.fingerPrint(), dataplaneTokenVersions.get(0).tokenVersions().get(0).fingerPrint());
+ }
+
+ @Test
+ void generating_new_token_appends() {
+ DataplaneToken dataplaneToken1 = dataplaneTokenService.generateToken(tenantName, tokenId, principal);
+ DataplaneToken dataplaneToken2 = dataplaneTokenService.generateToken(tenantName, tokenId, principal);
+ assertNotEquals(dataplaneToken1.fingerPrint(), dataplaneToken2.fingerPrint());
+
+ List<DataplaneTokenVersions> dataplaneTokenVersions = dataplaneTokenService.listTokens(tenantName);
+ List<FingerPrint> tokenFingerprints = dataplaneTokenVersions.stream()
+ .filter(token -> token.tokenId().equals(tokenId))
+ .map(DataplaneTokenVersions::tokenVersions)
+ .flatMap(Collection::stream)
+ .map(DataplaneTokenVersions.Version::fingerPrint)
+ .toList();
+ assertThat(tokenFingerprints).containsExactlyInAnyOrder(dataplaneToken1.fingerPrint(), dataplaneToken2.fingerPrint());
+ }
+
+ @Test
+ void delete_last_fingerprint_deletes_token() {
+ DataplaneToken dataplaneToken1 = dataplaneTokenService.generateToken(tenantName, tokenId, principal);
+ DataplaneToken dataplaneToken2 = dataplaneTokenService.generateToken(tenantName, tokenId, principal);
+ dataplaneTokenService.deleteToken(tenantName, tokenId, dataplaneToken1.fingerPrint());
+ dataplaneTokenService.deleteToken(tenantName, tokenId, dataplaneToken2.fingerPrint());
+ assertEquals(List.of(), dataplaneTokenService.listTokens(tenantName));
+ }
+
+ @Test
+ void deleting_nonexistent_fingerprint_throws() {
+ DataplaneToken dataplaneToken = dataplaneTokenService.generateToken(tenantName, tokenId, principal);
+ DataplaneToken dataplaneToken2 = dataplaneTokenService.generateToken(tenantName, tokenId, principal);
+ dataplaneTokenService.deleteToken(tenantName, tokenId, dataplaneToken.fingerPrint());
+
+ // Token currently contains value of "dataplaneToken2"
+ IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> dataplaneTokenService.deleteToken(tenantName, tokenId, dataplaneToken.fingerPrint()));
+ assertEquals("Fingerprint does not exist: " + dataplaneToken.fingerPrint(), exception.getMessage());
+ }
+
+ @Test
+ void deleting_nonexistent_token_throws() {
+ DataplaneToken dataplaneToken = dataplaneTokenService.generateToken(tenantName, tokenId, principal);
+ dataplaneTokenService.deleteToken(tenantName, tokenId, dataplaneToken.fingerPrint());
+
+ // Token is created and deleted above, no longer exists
+ IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> dataplaneTokenService.deleteToken(tenantName, tokenId, dataplaneToken.fingerPrint()));
+ assertEquals("Token does not exist: " + tokenId, exception.getMessage());
+ }
+}