Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
gitpod-io
GitHub Repository: gitpod-io/gitpod
Path: blob/main/components/public-api-server/pkg/apiv1/tokens.go
2499 views
1
// Copyright (c) 2022 Gitpod GmbH. All rights reserved.
2
// Licensed under the GNU Affero General Public License (AGPL).
3
// See License.AGPL.txt in the project root for license information.
4
5
package apiv1
6
7
import (
8
"context"
9
"errors"
10
"fmt"
11
"regexp"
12
"sort"
13
"strings"
14
15
connect "github.com/bufbuild/connect-go"
16
"github.com/gitpod-io/gitpod/common-go/experiments"
17
"github.com/gitpod-io/gitpod/common-go/log"
18
db "github.com/gitpod-io/gitpod/components/gitpod-db/go"
19
v1 "github.com/gitpod-io/gitpod/components/public-api/go/experimental/v1"
20
"github.com/gitpod-io/gitpod/components/public-api/go/experimental/v1/v1connect"
21
protocol "github.com/gitpod-io/gitpod/gitpod-protocol"
22
"github.com/gitpod-io/gitpod/public-api-server/pkg/auth"
23
"github.com/gitpod-io/gitpod/public-api-server/pkg/proxy"
24
"github.com/google/go-cmp/cmp"
25
"github.com/google/uuid"
26
"google.golang.org/protobuf/types/known/fieldmaskpb"
27
"google.golang.org/protobuf/types/known/timestamppb"
28
"gorm.io/gorm"
29
)
30
31
func NewTokensService(connPool proxy.ServerConnectionPool, expClient experiments.Client, dbConn *gorm.DB, signer auth.Signer) *TokensService {
32
return &TokensService{
33
connectionPool: connPool,
34
expClient: expClient,
35
dbConn: dbConn,
36
signer: signer,
37
}
38
}
39
40
type TokensService struct {
41
connectionPool proxy.ServerConnectionPool
42
expClient experiments.Client
43
dbConn *gorm.DB
44
signer auth.Signer
45
46
v1connect.UnimplementedTokensServiceHandler
47
}
48
49
func (s *TokensService) CreatePersonalAccessToken(ctx context.Context, req *connect.Request[v1.CreatePersonalAccessTokenRequest]) (*connect.Response[v1.CreatePersonalAccessTokenResponse], error) {
50
tokenReq := req.Msg.GetToken()
51
52
name, err := validatePersonalAccessTokenName(tokenReq.GetName())
53
if err != nil {
54
return nil, err
55
}
56
57
expiry := tokenReq.GetExpirationTime()
58
if !expiry.IsValid() {
59
return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("Received invalid Expiration Time, it is a required parameter."))
60
}
61
62
scopes, err := validateScopes(tokenReq.GetScopes())
63
if err != nil {
64
return nil, err
65
}
66
67
conn, err := getConnection(ctx, s.connectionPool)
68
if err != nil {
69
return nil, err
70
}
71
72
_, userID, err := s.getUser(ctx, conn)
73
if err != nil {
74
return nil, err
75
}
76
77
pat, err := auth.GeneratePersonalAccessToken(s.signer)
78
if err != nil {
79
log.Extract(ctx).WithError(err).Errorf("Failed to generate personal access token for user %s", userID.String())
80
return nil, connect.NewError(connect.CodeInternal, errors.New("Failed to generate personal access token."))
81
}
82
83
token, err := db.CreatePersonalAccessToken(ctx, s.dbConn, db.PersonalAccessToken{
84
ID: uuid.New(),
85
UserID: userID,
86
Hash: pat.ValueHash(),
87
Name: name,
88
Scopes: scopes,
89
ExpirationTime: expiry.AsTime().UTC(),
90
})
91
if err != nil {
92
log.Extract(ctx).WithError(err).Errorf("Failed to store personal access token for user %s", userID.String())
93
return nil, connect.NewError(connect.CodeInternal, errors.New("Failed to store personal access token."))
94
}
95
96
return connect.NewResponse(&v1.CreatePersonalAccessTokenResponse{
97
Token: personalAccessTokenToAPI(token, pat.String()),
98
}), nil
99
}
100
101
func (s *TokensService) GetPersonalAccessToken(ctx context.Context, req *connect.Request[v1.GetPersonalAccessTokenRequest]) (*connect.Response[v1.GetPersonalAccessTokenResponse], error) {
102
tokenID, err := validatePersonalAccessTokenID(ctx, req.Msg.GetId())
103
if err != nil {
104
return nil, err
105
}
106
107
conn, err := getConnection(ctx, s.connectionPool)
108
if err != nil {
109
return nil, err
110
}
111
112
_, userID, err := s.getUser(ctx, conn)
113
if err != nil {
114
return nil, err
115
}
116
117
token, err := db.GetPersonalAccessTokenForUser(ctx, s.dbConn, tokenID, userID)
118
if err != nil {
119
if errors.Is(err, db.ErrorNotFound) {
120
return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("Personal Access Token with ID %s for User %s does not exist", tokenID.String(), userID.String()))
121
}
122
123
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("Failed to get Personal Access Token with ID %s", tokenID.String()))
124
}
125
126
return connect.NewResponse(&v1.GetPersonalAccessTokenResponse{Token: personalAccessTokenToAPI(token, "")}), nil
127
}
128
129
func (s *TokensService) ListPersonalAccessTokens(ctx context.Context, req *connect.Request[v1.ListPersonalAccessTokensRequest]) (*connect.Response[v1.ListPersonalAccessTokensResponse], error) {
130
conn, err := getConnection(ctx, s.connectionPool)
131
if err != nil {
132
return nil, err
133
}
134
135
_, userID, err := s.getUser(ctx, conn)
136
if err != nil {
137
return nil, err
138
}
139
140
result, err := db.ListPersonalAccessTokensForUser(ctx, s.dbConn, userID, paginationToDB(req.Msg.GetPagination()))
141
if err != nil {
142
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("Failed to list Personal Access Tokens for User %s", userID.String()))
143
}
144
145
return connect.NewResponse(&v1.ListPersonalAccessTokensResponse{
146
Tokens: personalAccessTokensToAPI(result.Results),
147
TotalResults: result.Total,
148
}), nil
149
}
150
151
func (s *TokensService) RegeneratePersonalAccessToken(ctx context.Context, req *connect.Request[v1.RegeneratePersonalAccessTokenRequest]) (*connect.Response[v1.RegeneratePersonalAccessTokenResponse], error) {
152
tokenID, err := validatePersonalAccessTokenID(ctx, req.Msg.GetId())
153
if err != nil {
154
return nil, err
155
}
156
157
expiry := req.Msg.GetExpirationTime()
158
if !expiry.IsValid() {
159
return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("Received invalid Expiration Time, it is a required parameter."))
160
}
161
162
conn, err := getConnection(ctx, s.connectionPool)
163
if err != nil {
164
return nil, err
165
}
166
167
_, userID, err := s.getUser(ctx, conn)
168
if err != nil {
169
return nil, err
170
}
171
pat, err := auth.GeneratePersonalAccessToken(s.signer)
172
if err != nil {
173
log.Extract(ctx).WithError(err).Errorf("Failed to regenerate personal access token for user %s", userID.String())
174
return nil, connect.NewError(connect.CodeInternal, errors.New("Failed to regenerate personal access token."))
175
}
176
177
hash := pat.ValueHash()
178
token, err := db.UpdatePersonalAccessTokenHash(ctx, s.dbConn, tokenID, userID, hash, expiry.AsTime().UTC())
179
if err != nil {
180
if errors.Is(err, db.ErrorNotFound) {
181
return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("Personal Access Token with ID %s for User %s does not exist", tokenID.String(), userID.String()))
182
}
183
184
log.Extract(ctx).WithError(err).Errorf("Failed to store personal access token for user %s", userID.String())
185
return nil, connect.NewError(connect.CodeInternal, errors.New("Failed to store personal access token."))
186
}
187
188
return connect.NewResponse(&v1.RegeneratePersonalAccessTokenResponse{
189
Token: personalAccessTokenToAPI(token, pat.String()),
190
}), nil
191
}
192
193
func (s *TokensService) UpdatePersonalAccessToken(ctx context.Context, req *connect.Request[v1.UpdatePersonalAccessTokenRequest]) (*connect.Response[v1.UpdatePersonalAccessTokenResponse], error) {
194
const (
195
nameField = "name"
196
scopesField = "scopes"
197
)
198
var (
199
updatableMask = fieldmaskpb.FieldMask{Paths: []string{nameField, scopesField}}
200
)
201
202
tokenReq := req.Msg.GetToken()
203
204
tokenID, err := validatePersonalAccessTokenID(ctx, tokenReq.GetId())
205
if err != nil {
206
return nil, err
207
}
208
209
mask, err := validateFieldMask(req.Msg.GetUpdateMask(), tokenReq)
210
if err != nil {
211
return nil, err
212
}
213
214
// If no mask fields are specified, we treat the request as updating all updatable fields
215
if len(mask.GetPaths()) == 0 {
216
mask = &updatableMask
217
}
218
219
conn, err := getConnection(ctx, s.connectionPool)
220
if err != nil {
221
return nil, err
222
}
223
224
_, userID, err := s.getUser(ctx, conn)
225
if err != nil {
226
return nil, err
227
}
228
229
toUpdate := fieldmaskpb.Intersect(mask, &updatableMask)
230
updateOpts := db.UpdatePersonalAccessTokenOpts{
231
TokenID: tokenID,
232
UserID: userID,
233
}
234
235
for _, path := range toUpdate.GetPaths() {
236
switch path {
237
case nameField:
238
name, err := validatePersonalAccessTokenName(tokenReq.GetName())
239
if err != nil {
240
return nil, err
241
}
242
243
updateOpts.Name = &name
244
case scopesField:
245
scopes, err := validateScopes(tokenReq.GetScopes())
246
if err != nil {
247
return nil, err
248
}
249
dbScopes := db.Scopes(scopes)
250
updateOpts.Scopes = &dbScopes
251
}
252
}
253
254
token, err := db.UpdatePersonalAccessTokenForUser(ctx, s.dbConn, updateOpts)
255
if err != nil {
256
if errors.Is(err, db.ErrorNotFound) {
257
return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("Personal Access Token with ID %s for User %s does not exist", tokenID.String(), userID.String()))
258
}
259
260
log.Extract(ctx).WithError(err).Error("Failed to update PAT for user")
261
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("Failed to update token (ID %s) for user (ID %s).", tokenID.String(), userID.String()))
262
}
263
264
return connect.NewResponse(&v1.UpdatePersonalAccessTokenResponse{
265
Token: personalAccessTokenToAPI(token, ""),
266
}), nil
267
}
268
269
func (s *TokensService) DeletePersonalAccessToken(ctx context.Context, req *connect.Request[v1.DeletePersonalAccessTokenRequest]) (*connect.Response[v1.DeletePersonalAccessTokenResponse], error) {
270
tokenID, err := validatePersonalAccessTokenID(ctx, req.Msg.GetId())
271
if err != nil {
272
return nil, err
273
}
274
275
conn, err := getConnection(ctx, s.connectionPool)
276
if err != nil {
277
return nil, err
278
}
279
280
_, userID, err := s.getUser(ctx, conn)
281
if err != nil {
282
return nil, err
283
}
284
285
_, err = db.DeletePersonalAccessTokenForUser(ctx, s.dbConn, tokenID, userID)
286
if err != nil {
287
if errors.Is(err, db.ErrorNotFound) {
288
return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("Personal Access Token with ID %s for User %s does not exist", tokenID.String(), userID.String()))
289
}
290
291
log.Extract(ctx).WithError(err).Errorf("failed to delete personal access token (ID: %s) for user %s", tokenID.String(), userID.String())
292
return nil, connect.NewError(connect.CodeInternal, errors.New("Failed to delete personal access token."))
293
}
294
295
return connect.NewResponse(&v1.DeletePersonalAccessTokenResponse{}), nil
296
}
297
298
func (s *TokensService) getUser(ctx context.Context, conn protocol.APIInterface) (*protocol.User, uuid.UUID, error) {
299
user, err := conn.GetLoggedInUser(ctx)
300
if err != nil {
301
return nil, uuid.Nil, proxy.ConvertError(err)
302
}
303
304
log.AddFields(ctx, log.UserID(user.ID))
305
306
userID, err := uuid.Parse(user.ID)
307
if err != nil {
308
return nil, uuid.Nil, connect.NewError(connect.CodeInternal, errors.New("Failed to parse user ID as UUID. Please contact support."))
309
}
310
311
return user, userID, nil
312
}
313
314
func getConnection(ctx context.Context, pool proxy.ServerConnectionPool) (protocol.APIInterface, error) {
315
token, err := auth.TokenFromContext(ctx)
316
if err != nil {
317
return nil, connect.NewError(connect.CodeUnauthenticated, fmt.Errorf("No credentials present on request."))
318
}
319
320
conn, err := pool.Get(ctx, token)
321
if err != nil {
322
log.Extract(ctx).WithError(err).Error("Failed to get connection to server.")
323
return nil, connect.NewError(connect.CodeInternal, errors.New("Failed to establish connection to downstream services. If this issue persists, please contact Gitpod Support."))
324
}
325
326
return conn, nil
327
}
328
329
func personalAccessTokensToAPI(ts []db.PersonalAccessToken) []*v1.PersonalAccessToken {
330
var tokens []*v1.PersonalAccessToken
331
for _, t := range ts {
332
tokens = append(tokens, personalAccessTokenToAPI(t, ""))
333
}
334
335
return tokens
336
}
337
338
func personalAccessTokenToAPI(t db.PersonalAccessToken, value string) *v1.PersonalAccessToken {
339
return &v1.PersonalAccessToken{
340
Id: t.ID.String(),
341
// value is only present when the token is first created, or regenerated. It's empty for all subsequent requests.
342
Value: value,
343
Name: t.Name,
344
Scopes: t.Scopes,
345
ExpirationTime: timestamppb.New(t.ExpirationTime),
346
CreatedAt: timestamppb.New(t.CreatedAt),
347
}
348
}
349
350
var (
351
// alpha-numeric characters, dashes, underscore, spaces, between 3 and 63 chars
352
personalAccessTokenNameRegex = regexp.MustCompile(`^[a-zA-Z0-9-_ ]{3,63}$`)
353
)
354
355
func validatePersonalAccessTokenName(name string) (string, error) {
356
trimmed := strings.TrimSpace(name)
357
if trimmed == "" {
358
return "", connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("Token Name is a required parameter, but got empty."))
359
}
360
361
if !personalAccessTokenNameRegex.MatchString(trimmed) {
362
return "", connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("Token Name is required to match regexp %s.", personalAccessTokenNameRegex.String()))
363
}
364
365
return trimmed, nil
366
}
367
368
const (
369
allFunctionsScope = "function:*"
370
defaultResourceScope = "resource:default"
371
)
372
373
func validateScopes(scopes []string) ([]string, error) {
374
// Currently we do not have support for fine grained permissions, and therefore do not support fine-grained scopes.
375
// Therefore, for now we operate in one of the following modes:
376
// * Token has no scopes - represented as the empty list of scopes
377
// * Token explicitly has access to everything the user has access to, represented as ["function:*", "resource:default"]
378
if len(scopes) == 0 {
379
return nil, nil
380
}
381
382
sort.Strings(scopes)
383
allScopesSorted := []string{allFunctionsScope, defaultResourceScope}
384
385
if cmp.Equal(scopes, allScopesSorted) {
386
return scopes, nil
387
}
388
389
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))
390
}
391
392