Skip to content

Commit 5642845

Browse files
easyCZroboquat
authored andcommitted
[pat] Update Personal Access Token
1 parent f681d85 commit 5642845

File tree

4 files changed

+269
-12
lines changed

4 files changed

+269
-12
lines changed

components/gitpod-db/go/personal_access_token.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,56 @@ func ListPersonalAccessTokensForUser(ctx context.Context, conn *gorm.DB, userID
180180
}, nil
181181
}
182182

183+
type UpdatePersonalAccessTokenOpts struct {
184+
TokenID uuid.UUID
185+
UserID uuid.UUID
186+
Name *string
187+
Scopes *Scopes
188+
}
189+
190+
func UpdatePersonalAccessTokenForUser(ctx context.Context, conn *gorm.DB, opts UpdatePersonalAccessTokenOpts) (PersonalAccessToken, error) {
191+
if opts.TokenID == uuid.Nil {
192+
return PersonalAccessToken{}, errors.New("Token ID is required to udpate personal access token for user")
193+
}
194+
if opts.UserID == uuid.Nil {
195+
return PersonalAccessToken{}, errors.New("User ID is required to udpate personal access token for user")
196+
}
197+
198+
var cols []string
199+
update := PersonalAccessToken{}
200+
if opts.Name != nil {
201+
cols = append(cols, "name")
202+
update.Name = *opts.Name
203+
}
204+
205+
if opts.Scopes != nil {
206+
cols = append(cols, "scopes")
207+
update.Scopes = *opts.Scopes
208+
}
209+
210+
if len(cols) == 0 {
211+
return GetPersonalAccessTokenForUser(ctx, conn, opts.TokenID, opts.UserID)
212+
}
213+
214+
tx := conn.
215+
WithContext(ctx).
216+
Table((&PersonalAccessToken{}).TableName()).
217+
Where("id = ?", opts.TokenID).
218+
Where("userId = ?", opts.UserID).
219+
Where("deleted = ?", 0).
220+
Select(cols).
221+
Updates(update)
222+
if tx.Error != nil {
223+
return PersonalAccessToken{}, fmt.Errorf("failed to update personal access token: %w", tx.Error)
224+
}
225+
226+
if tx.RowsAffected == 0 {
227+
return PersonalAccessToken{}, fmt.Errorf("token (ID: %s) for user (ID: %s) does not exist: %w", opts.TokenID, opts.UserID, ErrorNotFound)
228+
}
229+
230+
return GetPersonalAccessTokenForUser(ctx, conn, opts.TokenID, opts.UserID)
231+
}
232+
183233
type Scopes []string
184234

185235
// Scan() and Value() allow having a list of strings as a type for Scopes

components/gitpod-db/go/personal_access_token_test.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,3 +238,71 @@ func TestListPersonalAccessTokensForUser_PaginateThroughResults(t *testing.T) {
238238
require.Len(t, batch3.Results, 1)
239239
require.EqualValues(t, batch3.Total, total)
240240
}
241+
242+
func TestUpdatePersonalAccessTokenForUser(t *testing.T) {
243+
244+
newName := "second"
245+
newScopes := db.Scopes([]string{})
246+
247+
t.Run("not found when record does not exist", func(t *testing.T) {
248+
conn := dbtest.ConnectForTests(t)
249+
250+
_, err := db.UpdatePersonalAccessTokenForUser(context.Background(), conn, db.UpdatePersonalAccessTokenOpts{
251+
TokenID: uuid.New(),
252+
UserID: uuid.New(),
253+
Name: &newName,
254+
})
255+
require.Error(t, err)
256+
require.ErrorIs(t, err, db.ErrorNotFound)
257+
})
258+
259+
t.Run("no update when all options are nil", func(t *testing.T) {
260+
conn := dbtest.ConnectForTests(t)
261+
262+
created := dbtest.CreatePersonalAccessTokenRecords(t, conn, dbtest.NewPersonalAccessToken(t, db.PersonalAccessToken{
263+
Name: "first",
264+
}))[0]
265+
266+
udpated, err := db.UpdatePersonalAccessTokenForUser(context.Background(), conn, db.UpdatePersonalAccessTokenOpts{
267+
TokenID: created.ID,
268+
UserID: created.UserID,
269+
})
270+
require.NoError(t, err)
271+
require.Equal(t, created, udpated)
272+
})
273+
274+
t.Run("updates name when it is not nil", func(t *testing.T) {
275+
conn := dbtest.ConnectForTests(t)
276+
277+
created := dbtest.CreatePersonalAccessTokenRecords(t, conn, dbtest.NewPersonalAccessToken(t, db.PersonalAccessToken{
278+
Name: "first",
279+
}))[0]
280+
281+
udpated, err := db.UpdatePersonalAccessTokenForUser(context.Background(), conn, db.UpdatePersonalAccessTokenOpts{
282+
TokenID: created.ID,
283+
UserID: created.UserID,
284+
Name: &newName,
285+
})
286+
require.NoError(t, err)
287+
require.Equal(t, newName, udpated.Name)
288+
require.Equal(t, created.Scopes, udpated.Scopes)
289+
})
290+
291+
t.Run("updates scopes when it is not nil", func(t *testing.T) {
292+
conn := dbtest.ConnectForTests(t)
293+
294+
created := dbtest.CreatePersonalAccessTokenRecords(t, conn, dbtest.NewPersonalAccessToken(t, db.PersonalAccessToken{
295+
Name: "first",
296+
Scopes: db.Scopes([]string{"foo"}),
297+
}))[0]
298+
299+
udpated, err := db.UpdatePersonalAccessTokenForUser(context.Background(), conn, db.UpdatePersonalAccessTokenOpts{
300+
TokenID: created.ID,
301+
UserID: created.UserID,
302+
Scopes: &newScopes,
303+
})
304+
require.NoError(t, err)
305+
require.Equal(t, created.Name, udpated.Name)
306+
require.Len(t, udpated.Scopes, len(newScopes))
307+
})
308+
}

components/public-api-server/pkg/apiv1/tokens.go

Lines changed: 72 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import (
1212
"sort"
1313
"strings"
1414

15+
"google.golang.org/protobuf/proto"
16+
1517
connect "github.com/bufbuild/connect-go"
1618
"github.com/gitpod-io/gitpod/common-go/experiments"
1719
"github.com/gitpod-io/gitpod/common-go/log"
@@ -23,6 +25,7 @@ import (
2325
"github.com/gitpod-io/gitpod/public-api-server/pkg/proxy"
2426
"github.com/google/go-cmp/cmp"
2527
"github.com/google/uuid"
28+
"google.golang.org/protobuf/types/known/fieldmaskpb"
2629
"google.golang.org/protobuf/types/known/timestamppb"
2730
"gorm.io/gorm"
2831
)
@@ -198,23 +201,75 @@ func (s *TokensService) RegeneratePersonalAccessToken(ctx context.Context, req *
198201
}
199202

200203
func (s *TokensService) UpdatePersonalAccessToken(ctx context.Context, req *connect.Request[v1.UpdatePersonalAccessTokenRequest]) (*connect.Response[v1.UpdatePersonalAccessTokenResponse], error) {
201-
tokenID, err := validateTokenID(req.Msg.GetToken().GetId())
204+
const (
205+
nameField = "name"
206+
scopesField = "scopes"
207+
)
208+
var (
209+
updatableMask = fieldmaskpb.FieldMask{Paths: []string{nameField, scopesField}}
210+
)
211+
212+
tokenReq := req.Msg.GetToken()
213+
214+
tokenID, err := validateTokenID(tokenReq.GetId())
215+
if err != nil {
216+
return nil, err
217+
}
218+
219+
mask, err := validateFieldMask(req.Msg.GetUpdateMask(), tokenReq)
202220
if err != nil {
203221
return nil, err
204222
}
205223

224+
// If no mask fields are specified, we treat the request as updating all updatable fields
225+
if len(mask.GetPaths()) == 0 {
226+
mask = &updatableMask
227+
}
228+
206229
conn, err := getConnection(ctx, s.connectionPool)
207230
if err != nil {
208231
return nil, err
209232
}
210233

211-
_, _, err = s.getUser(ctx, conn)
234+
_, userID, err := s.getUser(ctx, conn)
212235
if err != nil {
213236
return nil, err
214237
}
215238

216-
log.Infof("Handling UpdatePersonalAccessToken request for Token ID '%s'", tokenID.String())
217-
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("gitpod.experimental.v1.TokensService.UpdatePersonalAccessToken is not implemented"))
239+
toUpdate := fieldmaskpb.Intersect(mask, &updatableMask)
240+
updateOpts := db.UpdatePersonalAccessTokenOpts{
241+
TokenID: tokenID,
242+
UserID: userID,
243+
}
244+
245+
for _, path := range toUpdate.GetPaths() {
246+
switch path {
247+
case nameField:
248+
name, err := validatePersonalAccessTokenName(tokenReq.GetName())
249+
if err != nil {
250+
return nil, err
251+
}
252+
253+
updateOpts.Name = &name
254+
case scopesField:
255+
scopes, err := validateScopes(tokenReq.GetScopes())
256+
if err != nil {
257+
return nil, err
258+
}
259+
dbScopes := db.Scopes(scopes)
260+
updateOpts.Scopes = &dbScopes
261+
}
262+
}
263+
264+
token, err := db.UpdatePersonalAccessTokenForUser(ctx, s.dbConn, updateOpts)
265+
if err != nil {
266+
log.WithError(err).Error("Failed to update PAT for user")
267+
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("Failed to update token (ID %s) for user (ID %s).", tokenID.String(), userID.String()))
268+
}
269+
270+
return connect.NewResponse(&v1.UpdatePersonalAccessTokenResponse{
271+
Token: personalAccessTokenToAPI(token, ""),
272+
}), nil
218273
}
219274

220275
func (s *TokensService) DeletePersonalAccessToken(ctx context.Context, req *connect.Request[v1.DeletePersonalAccessTokenRequest]) (*connect.Response[v1.DeletePersonalAccessTokenResponse], error) {
@@ -374,3 +429,16 @@ func validateScopes(scopes []string) ([]string, error) {
374429

375430
return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("Tokens can currently only have no scopes (empty), or all scopes represented as [%s, %s]", allFunctionsScope, defaultResourceScope))
376431
}
432+
433+
func validateFieldMask(mask *fieldmaskpb.FieldMask, message proto.Message) (*fieldmaskpb.FieldMask, error) {
434+
if mask == nil {
435+
return &fieldmaskpb.FieldMask{}, nil
436+
}
437+
438+
mask.Normalize()
439+
if !mask.IsValid(message) {
440+
return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("Invalid field mask specified."))
441+
}
442+
443+
return mask, nil
444+
}

components/public-api-server/pkg/apiv1/tokens_test.go

Lines changed: 79 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import (
1313
"testing"
1414
"time"
1515

16+
"google.golang.org/protobuf/types/known/fieldmaskpb"
17+
1618
connect "github.com/bufbuild/connect-go"
1719
"github.com/gitpod-io/gitpod/common-go/experiments"
1820
"github.com/gitpod-io/gitpod/common-go/experiments/experimentstest"
@@ -514,10 +516,16 @@ func TestTokensService_UpdatePersonalAccessToken(t *testing.T) {
514516
serverMock.EXPECT().GetLoggedInUser(gomock.Any()).Return(user, nil)
515517
serverMock.EXPECT().GetTeams(gomock.Any()).Return(teams, nil)
516518

517-
_, err := client.UpdatePersonalAccessToken(context.Background(), connect.NewRequest(&v1.UpdatePersonalAccessTokenRequest{
518-
Token: &v1.PersonalAccessToken{
519-
Id: uuid.New().String(),
520-
},
519+
token := &v1.PersonalAccessToken{
520+
Id: uuid.New().String(),
521+
Name: "foo-bar",
522+
}
523+
mask, err := fieldmaskpb.New(token, "name")
524+
require.NoError(t, err)
525+
526+
_, err = client.UpdatePersonalAccessToken(context.Background(), connect.NewRequest(&v1.UpdatePersonalAccessTokenRequest{
527+
Token: token,
528+
UpdateMask: mask,
521529
}))
522530

523531
require.Error(t, err, "This feature is currently in beta. If you would like to be part of the beta, please contact us.")
@@ -544,18 +552,81 @@ func TestTokensService_UpdatePersonalAccessToken(t *testing.T) {
544552
require.Equal(t, connect.CodeInvalidArgument, connect.CodeOf(err))
545553
})
546554

547-
t.Run("unimplemented when feature flag enabled", func(t *testing.T) {
555+
t.Run("default updates both name and scopes, when no mask specified", func(t *testing.T) {
548556
serverMock, _, client := setupTokensService(t, withTokenFeatureEnabled)
549557

558+
serverMock.EXPECT().GetLoggedInUser(gomock.Any()).Return(user, nil).Times(2)
559+
560+
createResponse, err := client.CreatePersonalAccessToken(context.Background(), connect.NewRequest(&v1.CreatePersonalAccessTokenRequest{
561+
Token: &v1.PersonalAccessToken{
562+
Name: "first",
563+
ExpirationTime: timestamppb.Now(),
564+
},
565+
}))
566+
require.NoError(t, err)
567+
568+
updateResponse, err := client.UpdatePersonalAccessToken(context.Background(), connect.NewRequest(&v1.UpdatePersonalAccessTokenRequest{
569+
Token: &v1.PersonalAccessToken{
570+
Id: createResponse.Msg.GetToken().GetId(),
571+
Name: "second",
572+
Scopes: []string{allFunctionsScope, defaultResourceScope},
573+
},
574+
}))
575+
require.NoError(t, err)
576+
require.Equal(t, "second", updateResponse.Msg.GetToken().GetName())
577+
require.Equal(t, []string{allFunctionsScope, defaultResourceScope}, updateResponse.Msg.GetToken().GetScopes())
578+
})
579+
580+
t.Run("updates only name, when mask specifies name", func(t *testing.T) {
581+
serverMock, dbConn, client := setupTokensService(t, withTokenFeatureEnabled)
582+
550583
serverMock.EXPECT().GetLoggedInUser(gomock.Any()).Return(user, nil)
551584

552-
_, err := client.UpdatePersonalAccessToken(context.Background(), connect.NewRequest(&v1.UpdatePersonalAccessTokenRequest{
585+
created := dbtest.CreatePersonalAccessTokenRecords(t, dbConn, dbtest.NewPersonalAccessToken(t, db.PersonalAccessToken{
586+
Name: "first",
587+
UserID: uuid.MustParse(user.ID),
588+
Scopes: db.Scopes{allFunctionsScope, defaultResourceScope},
589+
}))[0]
590+
591+
updateResponse, err := client.UpdatePersonalAccessToken(context.Background(), connect.NewRequest(&v1.UpdatePersonalAccessTokenRequest{
553592
Token: &v1.PersonalAccessToken{
554-
Id: uuid.New().String(),
593+
Id: created.ID.String(),
594+
Name: "second",
595+
Scopes: []string{allFunctionsScope, defaultResourceScope},
596+
},
597+
UpdateMask: &fieldmaskpb.FieldMask{
598+
Paths: []string{"name"},
555599
},
556600
}))
601+
require.NoError(t, err)
602+
require.Equal(t, "second", updateResponse.Msg.GetToken().GetName())
603+
require.Equal(t, []string(created.Scopes), updateResponse.Msg.GetToken().GetScopes())
604+
})
557605

558-
require.Equal(t, connect.CodeUnimplemented, connect.CodeOf(err))
606+
t.Run("updates only scopes, when mask specifies scopes", func(t *testing.T) {
607+
serverMock, dbConn, client := setupTokensService(t, withTokenFeatureEnabled)
608+
609+
serverMock.EXPECT().GetLoggedInUser(gomock.Any()).Return(user, nil)
610+
611+
created := dbtest.CreatePersonalAccessTokenRecords(t, dbConn, dbtest.NewPersonalAccessToken(t, db.PersonalAccessToken{
612+
Name: "first",
613+
UserID: uuid.MustParse(user.ID),
614+
Scopes: db.Scopes{allFunctionsScope, defaultResourceScope},
615+
}))[0]
616+
617+
updateResponse, err := client.UpdatePersonalAccessToken(context.Background(), connect.NewRequest(&v1.UpdatePersonalAccessTokenRequest{
618+
Token: &v1.PersonalAccessToken{
619+
Id: created.ID.String(),
620+
Name: "second",
621+
Scopes: []string{allFunctionsScope, defaultResourceScope},
622+
},
623+
UpdateMask: &fieldmaskpb.FieldMask{
624+
Paths: []string{"scopes"},
625+
},
626+
}))
627+
require.NoError(t, err)
628+
require.Equal(t, "first", updateResponse.Msg.GetToken().GetName())
629+
require.Equal(t, []string{allFunctionsScope, defaultResourceScope}, updateResponse.Msg.GetToken().GetScopes())
559630
})
560631
}
561632

0 commit comments

Comments
 (0)