aboutsummaryrefslogtreecommitdiffstats
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
parent5f25e0ba346c04ccc27c60cc410c0ed2fdb6b06b (diff)
API to generate/list/delete dataplane tokens
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/dataplanetoken/DataplaneToken.java12
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/dataplanetoken/DataplaneTokenVersions.java15
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/dataplanetoken/FingerPrint.java23
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/dataplanetoken/TokenId.java23
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/PathGroup.java5
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/Policy.java6
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/RoleDefinition.java3
-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
14 files changed, 453 insertions, 5 deletions
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
new file mode 100644
index 00000000000..f0cc87df1fe
--- /dev/null
+++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/dataplanetoken/DataplaneToken.java
@@ -0,0 +1,12 @@
+// 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;
+
+/**
+ * Represents a generated data plane token.
+ *
+ * Note: This _MUST_ not be persisted.
+ *
+ * @author mortent
+ */
+public record DataplaneToken(TokenId tokenId, FingerPrint fingerPrint, String tokenValue) {
+}
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
new file mode 100644
index 00000000000..618bfbc8a41
--- /dev/null
+++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/dataplanetoken/DataplaneTokenVersions.java
@@ -0,0 +1,15 @@
+// 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.List;
+
+/**
+ * List of dataplane token versions of a token id.
+ *
+ * @author mortent
+ */
+public record DataplaneTokenVersions(TokenId tokenId, List<Version> tokenVersions) {
+ public record Version(FingerPrint fingerPrint, String checkAccessHash, Instant creationTime, String author) {
+ }
+}
diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/dataplanetoken/FingerPrint.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/dataplanetoken/FingerPrint.java
new file mode 100644
index 00000000000..3f019e8ae75
--- /dev/null
+++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/dataplanetoken/FingerPrint.java
@@ -0,0 +1,23 @@
+// 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 ai.vespa.validation.PatternedStringWrapper;
+
+import java.util.regex.Pattern;
+
+/**
+ * A fingerprint to be used in dataplane token apis
+ */
+public class FingerPrint extends PatternedStringWrapper<FingerPrint> {
+
+ static final Pattern namePattern = Pattern.compile("([a-f0-9]{2}:)+[a-f0-9]{2}");
+
+ private FingerPrint(String name) {
+ super(name, namePattern, "fingerPrint");
+ }
+
+ public static FingerPrint of(String value) {
+ return new FingerPrint(value);
+ }
+
+}
diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/dataplanetoken/TokenId.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/dataplanetoken/TokenId.java
new file mode 100644
index 00000000000..a1ddd8b4bce
--- /dev/null
+++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/dataplanetoken/TokenId.java
@@ -0,0 +1,23 @@
+// 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 ai.vespa.validation.PatternedStringWrapper;
+
+import java.util.regex.Pattern;
+
+/**
+ * A token id to be used in dataplane tokens
+ */
+public class TokenId extends PatternedStringWrapper<TokenId> {
+
+ static final Pattern namePattern = Pattern.compile("[A-Za-z][A-Za-z0-9_-]{0,59}");
+
+ private TokenId(String name) {
+ super(name, namePattern, "tokenId");
+ }
+
+ public static TokenId of(String value) {
+ return new TokenId(value);
+ }
+
+}
diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/PathGroup.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/PathGroup.java
index ccf79e7eca3..1a8f4103659 100644
--- a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/PathGroup.java
+++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/PathGroup.java
@@ -267,7 +267,10 @@ enum PathGroup {
"/application/v4/tenant/{tenant}/access/managed/operator"),
/** Path used for email verification */
- emailVerification("/user/v1/email/verify");
+ emailVerification("/user/v1/email/verify"),
+
+ /** Path used for dataplane token */
+ dataplaneToken(Matcher.tenant,"/application/v4/tenant/{tenant}/token", "/application/v4/tenant/{tenant}/token/{ignored}");
final List<String> pathSpecs;
final List<Matcher> matchers;
diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/Policy.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/Policy.java
index 2f8ea368b21..15d8d8dfdbe 100644
--- a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/Policy.java
+++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/Policy.java
@@ -216,7 +216,11 @@ enum Policy {
emailVerification(Privilege.grant(Action.create)
.on(PathGroup.emailVerification)
- .in(SystemName.PublicCd, SystemName.Public));
+ .in(SystemName.PublicCd, SystemName.Public)),
+
+ dataplaneToken(Privilege.grant(Action.all())
+ .on(PathGroup.dataplaneToken)
+ .in(SystemName.PublicCd, SystemName.Public));
private final Set<Privilege> privileges;
diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/RoleDefinition.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/RoleDefinition.java
index e40c99a64be..e3f9ba54e1a 100644
--- a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/RoleDefinition.java
+++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/RoleDefinition.java
@@ -59,7 +59,8 @@ public enum RoleDefinition {
Policy.paymentInstrumentRead,
Policy.paymentInstrumentDelete,
Policy.billingInformationRead,
- Policy.secretStoreOperations),
+ Policy.secretStoreOperations,
+ Policy.dataplaneToken),
/** Developer for manual deployments for a tenant */
hostedDeveloper(Policy.developmentDeployment),
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());
+ }
+}