From 362ddb0749608a5ace2be1caa5507ca9d3895eaf Mon Sep 17 00:00:00 2001 From: Morten Tokle Date: Mon, 12 Jun 2023 22:45:43 +0200 Subject: API to generate/list/delete dataplane tokens --- .../integration/dataplanetoken/DataplaneToken.java | 12 ++ .../dataplanetoken/DataplaneTokenVersions.java | 15 +++ .../integration/dataplanetoken/FingerPrint.java | 23 ++++ .../api/integration/dataplanetoken/TokenId.java | 23 ++++ .../hosted/controller/api/role/PathGroup.java | 5 +- .../vespa/hosted/controller/api/role/Policy.java | 6 +- .../hosted/controller/api/role/RoleDefinition.java | 3 +- .../yahoo/vespa/hosted/controller/Controller.java | 9 +- .../hosted/controller/persistence/CuratorDb.java | 16 +++ .../persistence/DataplaneTokenSerializer.java | 65 +++++++++++ .../restapi/application/ApplicationApiHandler.java | 42 +++++++ .../dataplanetoken/DataplaneTokenService.java | 121 +++++++++++++++++++++ .../application/ApplicationApiCloudTest.java | 36 ++++++ .../dataplanetoken/DataplaneTokenServiceTest.java | 82 ++++++++++++++ 14 files changed, 453 insertions(+), 5 deletions(-) create mode 100644 controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/dataplanetoken/DataplaneToken.java create mode 100644 controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/dataplanetoken/DataplaneTokenVersions.java create mode 100644 controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/dataplanetoken/FingerPrint.java create mode 100644 controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/dataplanetoken/TokenId.java create mode 100644 controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/DataplaneTokenSerializer.java create mode 100644 controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/dataplanetoken/DataplaneTokenService.java create mode 100644 controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/dataplanetoken/DataplaneTokenServiceTest.java 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 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 { + + 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 { + + 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 pathSpecs; final List 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 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) { + curator.set(dataplaneTokenPath(tenantName), asJson(DataplaneTokenSerializer.toSlime(dataplaneTokenVersions))); + } + + public List 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) { + 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 fromSlime(Slime slime) { + Cursor cursor = slime.get(); + return SlimeUtils.entriesStream(cursor.field(dataplaneTokenField)) + .map(entry -> { + TokenId id = TokenId.of(entry.field(idField).asString()); + List 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 = 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 boolean propertyEquals(HttpRequest request, String property, Function mapper, Optional 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 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 = curator.readDataplaneTokens(tenantName); + Optional existingToken = dataplaneTokenVersions.stream().filter(t -> Objects.equals(t.tokenId(), tokenId)).findFirst(); + if (existingToken.isPresent()) { + List 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 = curator.readDataplaneTokens(tenantName); + Optional existingToken = dataplaneTokenVersions.stream().filter(t -> Objects.equals(t.tokenId(), tokenId)).findFirst(); + if (existingToken.isPresent()) { + List 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 = 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 = dataplaneTokenService.listTokens(tenantName); + List 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()); + } +} -- cgit v1.2.3