All Downloads are FREE. Search and download functionalities are using the official Maven repository.

com.yahoo.vespa.hosted.controller.restapi.dataplanetoken.DataplaneTokenService Maven / Gradle / Ivy

// Copyright Vespa.ai. 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.concurrent.DaemonThreadFactory;
import com.yahoo.config.provision.ApplicationId;
import com.yahoo.config.provision.HostName;
import com.yahoo.config.provision.TenantName;
import com.yahoo.config.provision.zone.ZoneId;
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.Application;
import com.yahoo.vespa.hosted.controller.Controller;
import com.yahoo.vespa.hosted.controller.Instance;
import com.yahoo.vespa.hosted.controller.api.identifiers.DeploymentId;
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.DataplaneTokenVersions.Version;
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.JobType;
import com.yahoo.vespa.hosted.controller.application.Deployment;
import com.yahoo.vespa.hosted.controller.deployment.Run;
import com.yahoo.vespa.hosted.controller.persistence.CuratorDb;

import java.security.Principal;
import java.time.Duration;
import java.time.Instant;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.TreeMap;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Phaser;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import static java.util.Comparator.comparing;
import static java.util.Comparator.naturalOrder;
import static java.util.stream.Collectors.groupingBy;
import static java.util.stream.Collectors.toMap;

/**
 * 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 int TOKEN_BYTES = 32;
    private static final int CHECK_HASH_BYTES = 32;
    public static final Duration DEFAULT_TTL = Duration.ofDays(30);

    private final ExecutorService executor = Executors.newCachedThreadPool(new DaemonThreadFactory("dataplane-token-service-"));
    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);
    }

    public enum State { UNUSED, DEPLOYING, ACTIVE, REVOKING }

    /** List all known tokens for a tenant, with the state of each token version (both current and deactivating). */
    public Map> listTokensWithState(TenantName tenantName) {
        List currentTokens = listTokens(tenantName);
        Set usedTokens = new HashSet<>();
        Map>> activeTokens = listActiveTokens(tenantName, usedTokens);
        Map> activeFingerprints = computeStates(activeTokens);
        Map> tokens = new TreeMap<>(comparing(DataplaneTokenVersions::tokenId));
        for (DataplaneTokenVersions token : currentTokens) {
            Map states = new TreeMap<>();
            // Current tokens are active iff. they are active everywhere.
            for (Version version : token.tokenVersions()) {
                // If the token was not seen anywhere, it is deploying or unused.
                // Otherwise, it is active iff. it is active everywhere.
                Boolean isActive = activeFingerprints.getOrDefault(token.tokenId(), Map.of()).get(version.fingerPrint());
                states.put(version.fingerPrint(),
                           isActive == null ? usedTokens.contains(token.tokenId()) ? State.DEPLOYING : State.UNUSED
                                            : isActive ? State.ACTIVE : State.DEPLOYING);
            }
            // Active, non-current token versions are deactivating.
            for (FingerPrint print : activeFingerprints.getOrDefault(token.tokenId(), Map.of()).keySet()) {
                states.putIfAbsent(print, State.REVOKING);
            }
            tokens.put(token, states);
        }
        // Active, non-current tokens are also deactivating.
        activeFingerprints.forEach((id, prints) -> {
            if (currentTokens.stream().noneMatch(token -> token.tokenId().equals(id))) {
                Map states = new TreeMap<>();
                for (FingerPrint print : prints.keySet()) states.put(print, State.REVOKING);
                tokens.put(new DataplaneTokenVersions(id, List.of(), Instant.EPOCH), states);
            }
        });
        return tokens;
    }

    private Map>> listActiveTokens(TenantName tenantName, Set usedTokens) {
        Map>> tokens = new ConcurrentHashMap<>();
        Phaser phaser = new Phaser(1);
        for (Application application : controller.applications().asList(tenantName)) {
            for (Instance instance : application.instances().values()) {
                instance.deployments().forEach((zone, deployment) -> {
                    DeploymentId id = new DeploymentId(instance.id(), zone);
                    usedTokens.addAll(deployment.dataPlaneTokens().keySet());
                    phaser.register();
                    executor.execute(() -> {
                        try { tokens.putAll(controller.serviceRegistry().configServer().activeTokenFingerprints(id)); }
                        finally { phaser.arrive(); }
                    });
                });
            }
        }
        phaser.arriveAndAwaitAdvance();
        return tokens;
    }

    /** Computes whether each print is active on all hosts where its token is present. */
    private Map> computeStates(Map>> activeTokens) {
        Map> states = new HashMap<>();
        for (Map> token : activeTokens.values()) {
            token.forEach((id, prints) -> {
                states.merge(id,
                             prints.stream().collect(toMap(print -> print, __ -> true)),
                             (a, b) -> new HashMap<>() {{ // true iff. present in both, false iff. present in one.
                                 a.forEach((p, s) -> put(p, s && b.getOrDefault(p, false)));
                                 b.forEach((p, s) -> putIfAbsent(p, false));
                             }});
            });
        }
        return states;
    }

    /** Triggers redeployment of all applications which reference a token which has changed. */
    public void triggerTokenChangeDeployments() {
        controller.applications().asList().stream()
                .collect(groupingBy(application -> application.id().tenant()))
                .forEach((tenant, applications) -> {
                    List currentTokens = listTokens(tenant);
                    for (Application application : applications) {
                        for (Instance instance : application.instances().values()) {
                            instance.deployments().forEach((zone, deployment) -> {
                                if (zone.environment().isTest()) return;
                                if (deployment.dataPlaneTokens().isEmpty()) return;
                                boolean needsRetrigger = false;
                                // If a token has a newer change than the deployed token data, we need to re-trigger.
                                for (DataplaneTokenVersions token : currentTokens)
                                    needsRetrigger |= deployment.dataPlaneTokens().getOrDefault(token.tokenId(), Instant.MAX).isBefore(token.lastUpdated());

                                // If a token is no longer current, but was deployed with at least one version, we need to re-trigger.
                                for (var entry : deployment.dataPlaneTokens().entrySet())
                                    needsRetrigger |=    ! Instant.EPOCH.equals(entry.getValue())
                                                      &&   currentTokens.stream().noneMatch(token -> token.tokenId().equals(entry.getKey()));

                                if (needsRetrigger && controller.jobController().last(instance.id(), JobType.deploymentTo(zone)).map(Run::hasEnded).orElse(true))
                                    controller.applications().deploymentTrigger().reTrigger(instance.id(),
                                                                                            JobType.deploymentTo(zone),
                                                                                            "Data plane tokens changed");
                            });
                        }
                    }
                });
    }

    /**
     * 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 expiration Token expiration
     * @param principal The principal making the request
     * @return a DataplaneToken containing the secret generated token
     */
    public DataplaneToken generateToken(TenantName tenantName, TokenId tokenId, Instant expiration, Principal principal) {
        TokenDomain tokenDomain = TokenDomain.of("Vespa Cloud tenant data plane:%s".formatted(tenantName.value()));
        Token token = TokenGenerator.generateToken(tokenDomain, TOKEN_PREFIX, TOKEN_BYTES);
        TokenCheckHash checkHash = TokenCheckHash.of(token, CHECK_HASH_BYTES);
        Instant now = controller.clock().instant();
        DataplaneTokenVersions.Version newTokenVersion = new DataplaneTokenVersions.Version(
                FingerPrint.of(token.fingerprint().toDelimitedHexString()),
                checkHash.toHexString(),
                now,
                Optional.ofNullable(expiration),
                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, now)))
                        .toList();
            } else {
                DataplaneTokenVersions newToken = new DataplaneTokenVersions(tokenId, List.of(newTokenVersion), now);
                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(), Optional.ofNullable(expiration));
    }

    /**
     * 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 {
                    Optional existingVersion = existingToken.get().tokenVersions().stream().filter(v -> v.fingerPrint().equals(tokenFingerprint)).findAny();
                    if (existingVersion.isPresent()) {
                        Instant now = controller.clock().instant();
                        // If we removed an expired token, we keep the old lastUpdated timestamp.
                        Instant lastUpdated = existingVersion.get().expiration().map(now::isAfter).orElse(false) ? existingToken.get().lastUpdated() : now;
                        dataplaneTokenVersions = Stream.concat(dataplaneTokenVersions.stream().filter(t -> !Objects.equals(t.tokenId(), tokenId)),
                                                               Stream.of(new DataplaneTokenVersions(tokenId, versions, lastUpdated))).toList();
                    } else {
                        throw new IllegalArgumentException("Fingerprint does not exist: " + tokenFingerprint);
                    }
                }
                curator.writeDataplaneTokens(tenantName, dataplaneTokenVersions);
            } else {
                throw new IllegalArgumentException("Token does not exist: " + tokenId);
            }
        }
    }
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy