Path: blob/main/components/public-api-server/pkg/apiv1/tokens.go
2499 views
// Copyright (c) 2022 Gitpod GmbH. All rights reserved.1// Licensed under the GNU Affero General Public License (AGPL).2// See License.AGPL.txt in the project root for license information.34package apiv156import (7"context"8"errors"9"fmt"10"regexp"11"sort"12"strings"1314connect "github.com/bufbuild/connect-go"15"github.com/gitpod-io/gitpod/common-go/experiments"16"github.com/gitpod-io/gitpod/common-go/log"17db "github.com/gitpod-io/gitpod/components/gitpod-db/go"18v1 "github.com/gitpod-io/gitpod/components/public-api/go/experimental/v1"19"github.com/gitpod-io/gitpod/components/public-api/go/experimental/v1/v1connect"20protocol "github.com/gitpod-io/gitpod/gitpod-protocol"21"github.com/gitpod-io/gitpod/public-api-server/pkg/auth"22"github.com/gitpod-io/gitpod/public-api-server/pkg/proxy"23"github.com/google/go-cmp/cmp"24"github.com/google/uuid"25"google.golang.org/protobuf/types/known/fieldmaskpb"26"google.golang.org/protobuf/types/known/timestamppb"27"gorm.io/gorm"28)2930func NewTokensService(connPool proxy.ServerConnectionPool, expClient experiments.Client, dbConn *gorm.DB, signer auth.Signer) *TokensService {31return &TokensService{32connectionPool: connPool,33expClient: expClient,34dbConn: dbConn,35signer: signer,36}37}3839type TokensService struct {40connectionPool proxy.ServerConnectionPool41expClient experiments.Client42dbConn *gorm.DB43signer auth.Signer4445v1connect.UnimplementedTokensServiceHandler46}4748func (s *TokensService) CreatePersonalAccessToken(ctx context.Context, req *connect.Request[v1.CreatePersonalAccessTokenRequest]) (*connect.Response[v1.CreatePersonalAccessTokenResponse], error) {49tokenReq := req.Msg.GetToken()5051name, err := validatePersonalAccessTokenName(tokenReq.GetName())52if err != nil {53return nil, err54}5556expiry := tokenReq.GetExpirationTime()57if !expiry.IsValid() {58return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("Received invalid Expiration Time, it is a required parameter."))59}6061scopes, err := validateScopes(tokenReq.GetScopes())62if err != nil {63return nil, err64}6566conn, err := getConnection(ctx, s.connectionPool)67if err != nil {68return nil, err69}7071_, userID, err := s.getUser(ctx, conn)72if err != nil {73return nil, err74}7576pat, err := auth.GeneratePersonalAccessToken(s.signer)77if err != nil {78log.Extract(ctx).WithError(err).Errorf("Failed to generate personal access token for user %s", userID.String())79return nil, connect.NewError(connect.CodeInternal, errors.New("Failed to generate personal access token."))80}8182token, err := db.CreatePersonalAccessToken(ctx, s.dbConn, db.PersonalAccessToken{83ID: uuid.New(),84UserID: userID,85Hash: pat.ValueHash(),86Name: name,87Scopes: scopes,88ExpirationTime: expiry.AsTime().UTC(),89})90if err != nil {91log.Extract(ctx).WithError(err).Errorf("Failed to store personal access token for user %s", userID.String())92return nil, connect.NewError(connect.CodeInternal, errors.New("Failed to store personal access token."))93}9495return connect.NewResponse(&v1.CreatePersonalAccessTokenResponse{96Token: personalAccessTokenToAPI(token, pat.String()),97}), nil98}99100func (s *TokensService) GetPersonalAccessToken(ctx context.Context, req *connect.Request[v1.GetPersonalAccessTokenRequest]) (*connect.Response[v1.GetPersonalAccessTokenResponse], error) {101tokenID, err := validatePersonalAccessTokenID(ctx, req.Msg.GetId())102if err != nil {103return nil, err104}105106conn, err := getConnection(ctx, s.connectionPool)107if err != nil {108return nil, err109}110111_, userID, err := s.getUser(ctx, conn)112if err != nil {113return nil, err114}115116token, err := db.GetPersonalAccessTokenForUser(ctx, s.dbConn, tokenID, userID)117if err != nil {118if errors.Is(err, db.ErrorNotFound) {119return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("Personal Access Token with ID %s for User %s does not exist", tokenID.String(), userID.String()))120}121122return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("Failed to get Personal Access Token with ID %s", tokenID.String()))123}124125return connect.NewResponse(&v1.GetPersonalAccessTokenResponse{Token: personalAccessTokenToAPI(token, "")}), nil126}127128func (s *TokensService) ListPersonalAccessTokens(ctx context.Context, req *connect.Request[v1.ListPersonalAccessTokensRequest]) (*connect.Response[v1.ListPersonalAccessTokensResponse], error) {129conn, err := getConnection(ctx, s.connectionPool)130if err != nil {131return nil, err132}133134_, userID, err := s.getUser(ctx, conn)135if err != nil {136return nil, err137}138139result, err := db.ListPersonalAccessTokensForUser(ctx, s.dbConn, userID, paginationToDB(req.Msg.GetPagination()))140if err != nil {141return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("Failed to list Personal Access Tokens for User %s", userID.String()))142}143144return connect.NewResponse(&v1.ListPersonalAccessTokensResponse{145Tokens: personalAccessTokensToAPI(result.Results),146TotalResults: result.Total,147}), nil148}149150func (s *TokensService) RegeneratePersonalAccessToken(ctx context.Context, req *connect.Request[v1.RegeneratePersonalAccessTokenRequest]) (*connect.Response[v1.RegeneratePersonalAccessTokenResponse], error) {151tokenID, err := validatePersonalAccessTokenID(ctx, req.Msg.GetId())152if err != nil {153return nil, err154}155156expiry := req.Msg.GetExpirationTime()157if !expiry.IsValid() {158return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("Received invalid Expiration Time, it is a required parameter."))159}160161conn, err := getConnection(ctx, s.connectionPool)162if err != nil {163return nil, err164}165166_, userID, err := s.getUser(ctx, conn)167if err != nil {168return nil, err169}170pat, err := auth.GeneratePersonalAccessToken(s.signer)171if err != nil {172log.Extract(ctx).WithError(err).Errorf("Failed to regenerate personal access token for user %s", userID.String())173return nil, connect.NewError(connect.CodeInternal, errors.New("Failed to regenerate personal access token."))174}175176hash := pat.ValueHash()177token, err := db.UpdatePersonalAccessTokenHash(ctx, s.dbConn, tokenID, userID, hash, expiry.AsTime().UTC())178if err != nil {179if errors.Is(err, db.ErrorNotFound) {180return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("Personal Access Token with ID %s for User %s does not exist", tokenID.String(), userID.String()))181}182183log.Extract(ctx).WithError(err).Errorf("Failed to store personal access token for user %s", userID.String())184return nil, connect.NewError(connect.CodeInternal, errors.New("Failed to store personal access token."))185}186187return connect.NewResponse(&v1.RegeneratePersonalAccessTokenResponse{188Token: personalAccessTokenToAPI(token, pat.String()),189}), nil190}191192func (s *TokensService) UpdatePersonalAccessToken(ctx context.Context, req *connect.Request[v1.UpdatePersonalAccessTokenRequest]) (*connect.Response[v1.UpdatePersonalAccessTokenResponse], error) {193const (194nameField = "name"195scopesField = "scopes"196)197var (198updatableMask = fieldmaskpb.FieldMask{Paths: []string{nameField, scopesField}}199)200201tokenReq := req.Msg.GetToken()202203tokenID, err := validatePersonalAccessTokenID(ctx, tokenReq.GetId())204if err != nil {205return nil, err206}207208mask, err := validateFieldMask(req.Msg.GetUpdateMask(), tokenReq)209if err != nil {210return nil, err211}212213// If no mask fields are specified, we treat the request as updating all updatable fields214if len(mask.GetPaths()) == 0 {215mask = &updatableMask216}217218conn, err := getConnection(ctx, s.connectionPool)219if err != nil {220return nil, err221}222223_, userID, err := s.getUser(ctx, conn)224if err != nil {225return nil, err226}227228toUpdate := fieldmaskpb.Intersect(mask, &updatableMask)229updateOpts := db.UpdatePersonalAccessTokenOpts{230TokenID: tokenID,231UserID: userID,232}233234for _, path := range toUpdate.GetPaths() {235switch path {236case nameField:237name, err := validatePersonalAccessTokenName(tokenReq.GetName())238if err != nil {239return nil, err240}241242updateOpts.Name = &name243case scopesField:244scopes, err := validateScopes(tokenReq.GetScopes())245if err != nil {246return nil, err247}248dbScopes := db.Scopes(scopes)249updateOpts.Scopes = &dbScopes250}251}252253token, err := db.UpdatePersonalAccessTokenForUser(ctx, s.dbConn, updateOpts)254if err != nil {255if errors.Is(err, db.ErrorNotFound) {256return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("Personal Access Token with ID %s for User %s does not exist", tokenID.String(), userID.String()))257}258259log.Extract(ctx).WithError(err).Error("Failed to update PAT for user")260return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("Failed to update token (ID %s) for user (ID %s).", tokenID.String(), userID.String()))261}262263return connect.NewResponse(&v1.UpdatePersonalAccessTokenResponse{264Token: personalAccessTokenToAPI(token, ""),265}), nil266}267268func (s *TokensService) DeletePersonalAccessToken(ctx context.Context, req *connect.Request[v1.DeletePersonalAccessTokenRequest]) (*connect.Response[v1.DeletePersonalAccessTokenResponse], error) {269tokenID, err := validatePersonalAccessTokenID(ctx, req.Msg.GetId())270if err != nil {271return nil, err272}273274conn, err := getConnection(ctx, s.connectionPool)275if err != nil {276return nil, err277}278279_, userID, err := s.getUser(ctx, conn)280if err != nil {281return nil, err282}283284_, err = db.DeletePersonalAccessTokenForUser(ctx, s.dbConn, tokenID, userID)285if err != nil {286if errors.Is(err, db.ErrorNotFound) {287return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("Personal Access Token with ID %s for User %s does not exist", tokenID.String(), userID.String()))288}289290log.Extract(ctx).WithError(err).Errorf("failed to delete personal access token (ID: %s) for user %s", tokenID.String(), userID.String())291return nil, connect.NewError(connect.CodeInternal, errors.New("Failed to delete personal access token."))292}293294return connect.NewResponse(&v1.DeletePersonalAccessTokenResponse{}), nil295}296297func (s *TokensService) getUser(ctx context.Context, conn protocol.APIInterface) (*protocol.User, uuid.UUID, error) {298user, err := conn.GetLoggedInUser(ctx)299if err != nil {300return nil, uuid.Nil, proxy.ConvertError(err)301}302303log.AddFields(ctx, log.UserID(user.ID))304305userID, err := uuid.Parse(user.ID)306if err != nil {307return nil, uuid.Nil, connect.NewError(connect.CodeInternal, errors.New("Failed to parse user ID as UUID. Please contact support."))308}309310return user, userID, nil311}312313func getConnection(ctx context.Context, pool proxy.ServerConnectionPool) (protocol.APIInterface, error) {314token, err := auth.TokenFromContext(ctx)315if err != nil {316return nil, connect.NewError(connect.CodeUnauthenticated, fmt.Errorf("No credentials present on request."))317}318319conn, err := pool.Get(ctx, token)320if err != nil {321log.Extract(ctx).WithError(err).Error("Failed to get connection to server.")322return nil, connect.NewError(connect.CodeInternal, errors.New("Failed to establish connection to downstream services. If this issue persists, please contact Gitpod Support."))323}324325return conn, nil326}327328func personalAccessTokensToAPI(ts []db.PersonalAccessToken) []*v1.PersonalAccessToken {329var tokens []*v1.PersonalAccessToken330for _, t := range ts {331tokens = append(tokens, personalAccessTokenToAPI(t, ""))332}333334return tokens335}336337func personalAccessTokenToAPI(t db.PersonalAccessToken, value string) *v1.PersonalAccessToken {338return &v1.PersonalAccessToken{339Id: t.ID.String(),340// value is only present when the token is first created, or regenerated. It's empty for all subsequent requests.341Value: value,342Name: t.Name,343Scopes: t.Scopes,344ExpirationTime: timestamppb.New(t.ExpirationTime),345CreatedAt: timestamppb.New(t.CreatedAt),346}347}348349var (350// alpha-numeric characters, dashes, underscore, spaces, between 3 and 63 chars351personalAccessTokenNameRegex = regexp.MustCompile(`^[a-zA-Z0-9-_ ]{3,63}$`)352)353354func validatePersonalAccessTokenName(name string) (string, error) {355trimmed := strings.TrimSpace(name)356if trimmed == "" {357return "", connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("Token Name is a required parameter, but got empty."))358}359360if !personalAccessTokenNameRegex.MatchString(trimmed) {361return "", connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("Token Name is required to match regexp %s.", personalAccessTokenNameRegex.String()))362}363364return trimmed, nil365}366367const (368allFunctionsScope = "function:*"369defaultResourceScope = "resource:default"370)371372func validateScopes(scopes []string) ([]string, error) {373// Currently we do not have support for fine grained permissions, and therefore do not support fine-grained scopes.374// Therefore, for now we operate in one of the following modes:375// * Token has no scopes - represented as the empty list of scopes376// * Token explicitly has access to everything the user has access to, represented as ["function:*", "resource:default"]377if len(scopes) == 0 {378return nil, nil379}380381sort.Strings(scopes)382allScopesSorted := []string{allFunctionsScope, defaultResourceScope}383384if cmp.Equal(scopes, allScopesSorted) {385return scopes, nil386}387388return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("Tokens can currently only have no scopes (empty), or all scopes represented as [%s, %s]", allFunctionsScope, defaultResourceScope))389}390391392