From f7e48c68933083b10ead0a21bc7d94af895eeef1 Mon Sep 17 00:00:00 2001 From: Huiwen Huang Date: Mon, 15 Jan 2024 07:46:24 +0000 Subject: [PATCH 1/4] [papi] add token service to gitpod.v1 --- components/public-api/gitpod/v1/token.proto | 27 ++ components/public-api/go/v1/token.pb.go | 250 +++++++++++++++++ components/public-api/go/v1/token_grpc.pb.go | 113 ++++++++ .../go/v1/v1connect/token.connect.go | 94 +++++++ .../go/v1/v1connect/token.proxy.connect.go | 30 ++ .../typescript/src/gitpod/v1/token_connect.ts | 34 +++ .../typescript/src/gitpod/v1/token_pb.ts | 106 ++++++++ components/server/src/api/server.ts | 5 + .../server/src/api/token-service-api.ts | 69 +++++ .../server/src/authorization/definitions.ts | 1 + components/spicedb/schema/schema.yaml | 7 + test/tests/smoke-test/collaborator_test.go | 18 +- .../smoke-test/papi_create_temp_token_test.go | 256 ++++++++++++++++++ 13 files changed, 999 insertions(+), 11 deletions(-) create mode 100644 components/public-api/gitpod/v1/token.proto create mode 100644 components/public-api/go/v1/token.pb.go create mode 100644 components/public-api/go/v1/token_grpc.pb.go create mode 100644 components/public-api/go/v1/v1connect/token.connect.go create mode 100644 components/public-api/go/v1/v1connect/token.proxy.connect.go create mode 100644 components/public-api/typescript/src/gitpod/v1/token_connect.ts create mode 100644 components/public-api/typescript/src/gitpod/v1/token_pb.ts create mode 100644 components/server/src/api/token-service-api.ts create mode 100644 test/tests/smoke-test/papi_create_temp_token_test.go diff --git a/components/public-api/gitpod/v1/token.proto b/components/public-api/gitpod/v1/token.proto new file mode 100644 index 00000000000000..bc719b27d18ca2 --- /dev/null +++ b/components/public-api/gitpod/v1/token.proto @@ -0,0 +1,27 @@ +syntax = "proto3"; + +package gitpod.v1; + +option go_package = "github.com/gitpod-io/gitpod/components/public-api/go/v1"; + +service TokenService { + // CreateUserToken creates a new temporary access token for the specified user. + // +admin – only to be used by installation admins + rpc CreateTemporaryAccessToken(CreateTemporaryAccessTokenRequest) returns (CreateTemporaryAccessTokenResponse) {} +} + +message CreateTemporaryAccessTokenRequest { + // user_id is the identifier of the user for which the token is created. + string user_id = 1; + + // expiry_seconds is the number of seconds the token is valid for. + // value should in the range [1, 600] + int32 expiry_seconds = 2; +} + +message CreateTemporaryAccessTokenResponse { + // cookie_name is the name of the cookie to use for the token. + string cookie_name = 1; + + string token = 2; +} diff --git a/components/public-api/go/v1/token.pb.go b/components/public-api/go/v1/token.pb.go new file mode 100644 index 00000000000000..deeeafb96e0f2e --- /dev/null +++ b/components/public-api/go/v1/token.pb.go @@ -0,0 +1,250 @@ +// Copyright (c) 2024 Gitpod GmbH. All rights reserved. +// Licensed under the GNU Affero General Public License (AGPL). +// See License.AGPL.txt in the project root for license information. + +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.28.1 +// protoc (unknown) +// source: gitpod/v1/token.proto + +package v1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type CreateTemporaryAccessTokenRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // user_id is the identifier of the user for which the token is created. + UserId string `protobuf:"bytes,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + // expiry_seconds is the number of seconds the token is valid for. + // value should in the range [1, 600] + ExpirySeconds int32 `protobuf:"varint,2,opt,name=expiry_seconds,json=expirySeconds,proto3" json:"expiry_seconds,omitempty"` +} + +func (x *CreateTemporaryAccessTokenRequest) Reset() { + *x = CreateTemporaryAccessTokenRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_gitpod_v1_token_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *CreateTemporaryAccessTokenRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CreateTemporaryAccessTokenRequest) ProtoMessage() {} + +func (x *CreateTemporaryAccessTokenRequest) ProtoReflect() protoreflect.Message { + mi := &file_gitpod_v1_token_proto_msgTypes[0] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CreateTemporaryAccessTokenRequest.ProtoReflect.Descriptor instead. +func (*CreateTemporaryAccessTokenRequest) Descriptor() ([]byte, []int) { + return file_gitpod_v1_token_proto_rawDescGZIP(), []int{0} +} + +func (x *CreateTemporaryAccessTokenRequest) GetUserId() string { + if x != nil { + return x.UserId + } + return "" +} + +func (x *CreateTemporaryAccessTokenRequest) GetExpirySeconds() int32 { + if x != nil { + return x.ExpirySeconds + } + return 0 +} + +type CreateTemporaryAccessTokenResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // cookie_name is the name of the cookie to use for the token. + CookieName string `protobuf:"bytes,1,opt,name=cookie_name,json=cookieName,proto3" json:"cookie_name,omitempty"` + Token string `protobuf:"bytes,2,opt,name=token,proto3" json:"token,omitempty"` +} + +func (x *CreateTemporaryAccessTokenResponse) Reset() { + *x = CreateTemporaryAccessTokenResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_gitpod_v1_token_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *CreateTemporaryAccessTokenResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CreateTemporaryAccessTokenResponse) ProtoMessage() {} + +func (x *CreateTemporaryAccessTokenResponse) ProtoReflect() protoreflect.Message { + mi := &file_gitpod_v1_token_proto_msgTypes[1] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CreateTemporaryAccessTokenResponse.ProtoReflect.Descriptor instead. +func (*CreateTemporaryAccessTokenResponse) Descriptor() ([]byte, []int) { + return file_gitpod_v1_token_proto_rawDescGZIP(), []int{1} +} + +func (x *CreateTemporaryAccessTokenResponse) GetCookieName() string { + if x != nil { + return x.CookieName + } + return "" +} + +func (x *CreateTemporaryAccessTokenResponse) GetToken() string { + if x != nil { + return x.Token + } + return "" +} + +var File_gitpod_v1_token_proto protoreflect.FileDescriptor + +var file_gitpod_v1_token_proto_rawDesc = []byte{ + 0x0a, 0x15, 0x67, 0x69, 0x74, 0x70, 0x6f, 0x64, 0x2f, 0x76, 0x31, 0x2f, 0x74, 0x6f, 0x6b, 0x65, + 0x6e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x09, 0x67, 0x69, 0x74, 0x70, 0x6f, 0x64, 0x2e, + 0x76, 0x31, 0x22, 0x63, 0x0a, 0x21, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x54, 0x65, 0x6d, 0x70, + 0x6f, 0x72, 0x61, 0x72, 0x79, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, + 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, + 0x12, 0x25, 0x0a, 0x0e, 0x65, 0x78, 0x70, 0x69, 0x72, 0x79, 0x5f, 0x73, 0x65, 0x63, 0x6f, 0x6e, + 0x64, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x0d, 0x65, 0x78, 0x70, 0x69, 0x72, 0x79, + 0x53, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x73, 0x22, 0x5b, 0x0a, 0x22, 0x43, 0x72, 0x65, 0x61, 0x74, + 0x65, 0x54, 0x65, 0x6d, 0x70, 0x6f, 0x72, 0x61, 0x72, 0x79, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, + 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x1f, 0x0a, + 0x0b, 0x63, 0x6f, 0x6f, 0x6b, 0x69, 0x65, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x0a, 0x63, 0x6f, 0x6f, 0x6b, 0x69, 0x65, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x14, + 0x0a, 0x05, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x74, + 0x6f, 0x6b, 0x65, 0x6e, 0x32, 0x8b, 0x01, 0x0a, 0x0c, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x53, 0x65, + 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x7b, 0x0a, 0x1a, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x54, + 0x65, 0x6d, 0x70, 0x6f, 0x72, 0x61, 0x72, 0x79, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, + 0x6b, 0x65, 0x6e, 0x12, 0x2c, 0x2e, 0x67, 0x69, 0x74, 0x70, 0x6f, 0x64, 0x2e, 0x76, 0x31, 0x2e, + 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x54, 0x65, 0x6d, 0x70, 0x6f, 0x72, 0x61, 0x72, 0x79, 0x41, + 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x1a, 0x2d, 0x2e, 0x67, 0x69, 0x74, 0x70, 0x6f, 0x64, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x72, + 0x65, 0x61, 0x74, 0x65, 0x54, 0x65, 0x6d, 0x70, 0x6f, 0x72, 0x61, 0x72, 0x79, 0x41, 0x63, 0x63, + 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, + 0x22, 0x00, 0x42, 0x39, 0x5a, 0x37, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, + 0x2f, 0x67, 0x69, 0x74, 0x70, 0x6f, 0x64, 0x2d, 0x69, 0x6f, 0x2f, 0x67, 0x69, 0x74, 0x70, 0x6f, + 0x64, 0x2f, 0x63, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x73, 0x2f, 0x70, 0x75, 0x62, + 0x6c, 0x69, 0x63, 0x2d, 0x61, 0x70, 0x69, 0x2f, 0x67, 0x6f, 0x2f, 0x76, 0x31, 0x62, 0x06, 0x70, + 0x72, 0x6f, 0x74, 0x6f, 0x33, +} + +var ( + file_gitpod_v1_token_proto_rawDescOnce sync.Once + file_gitpod_v1_token_proto_rawDescData = file_gitpod_v1_token_proto_rawDesc +) + +func file_gitpod_v1_token_proto_rawDescGZIP() []byte { + file_gitpod_v1_token_proto_rawDescOnce.Do(func() { + file_gitpod_v1_token_proto_rawDescData = protoimpl.X.CompressGZIP(file_gitpod_v1_token_proto_rawDescData) + }) + return file_gitpod_v1_token_proto_rawDescData +} + +var file_gitpod_v1_token_proto_msgTypes = make([]protoimpl.MessageInfo, 2) +var file_gitpod_v1_token_proto_goTypes = []interface{}{ + (*CreateTemporaryAccessTokenRequest)(nil), // 0: gitpod.v1.CreateTemporaryAccessTokenRequest + (*CreateTemporaryAccessTokenResponse)(nil), // 1: gitpod.v1.CreateTemporaryAccessTokenResponse +} +var file_gitpod_v1_token_proto_depIdxs = []int32{ + 0, // 0: gitpod.v1.TokenService.CreateTemporaryAccessToken:input_type -> gitpod.v1.CreateTemporaryAccessTokenRequest + 1, // 1: gitpod.v1.TokenService.CreateTemporaryAccessToken:output_type -> gitpod.v1.CreateTemporaryAccessTokenResponse + 1, // [1:2] is the sub-list for method output_type + 0, // [0:1] is the sub-list for method input_type + 0, // [0:0] is the sub-list for extension type_name + 0, // [0:0] is the sub-list for extension extendee + 0, // [0:0] is the sub-list for field type_name +} + +func init() { file_gitpod_v1_token_proto_init() } +func file_gitpod_v1_token_proto_init() { + if File_gitpod_v1_token_proto != nil { + return + } + if !protoimpl.UnsafeEnabled { + file_gitpod_v1_token_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*CreateTemporaryAccessTokenRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_gitpod_v1_token_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*CreateTemporaryAccessTokenResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_gitpod_v1_token_proto_rawDesc, + NumEnums: 0, + NumMessages: 2, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_gitpod_v1_token_proto_goTypes, + DependencyIndexes: file_gitpod_v1_token_proto_depIdxs, + MessageInfos: file_gitpod_v1_token_proto_msgTypes, + }.Build() + File_gitpod_v1_token_proto = out.File + file_gitpod_v1_token_proto_rawDesc = nil + file_gitpod_v1_token_proto_goTypes = nil + file_gitpod_v1_token_proto_depIdxs = nil +} diff --git a/components/public-api/go/v1/token_grpc.pb.go b/components/public-api/go/v1/token_grpc.pb.go new file mode 100644 index 00000000000000..6249d1204f1aa7 --- /dev/null +++ b/components/public-api/go/v1/token_grpc.pb.go @@ -0,0 +1,113 @@ +// Copyright (c) 2024 Gitpod GmbH. All rights reserved. +// Licensed under the GNU Affero General Public License (AGPL). +// See License.AGPL.txt in the project root for license information. + +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.2.0 +// - protoc (unknown) +// source: gitpod/v1/token.proto + +package v1 + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.32.0 or later. +const _ = grpc.SupportPackageIsVersion7 + +// TokenServiceClient is the client API for TokenService service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +type TokenServiceClient interface { + // CreateUserToken creates a new temporary access token for the specified user. + // +admin – only to be used by installation admins + CreateTemporaryAccessToken(ctx context.Context, in *CreateTemporaryAccessTokenRequest, opts ...grpc.CallOption) (*CreateTemporaryAccessTokenResponse, error) +} + +type tokenServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewTokenServiceClient(cc grpc.ClientConnInterface) TokenServiceClient { + return &tokenServiceClient{cc} +} + +func (c *tokenServiceClient) CreateTemporaryAccessToken(ctx context.Context, in *CreateTemporaryAccessTokenRequest, opts ...grpc.CallOption) (*CreateTemporaryAccessTokenResponse, error) { + out := new(CreateTemporaryAccessTokenResponse) + err := c.cc.Invoke(ctx, "/gitpod.v1.TokenService/CreateTemporaryAccessToken", in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +// TokenServiceServer is the server API for TokenService service. +// All implementations must embed UnimplementedTokenServiceServer +// for forward compatibility +type TokenServiceServer interface { + // CreateUserToken creates a new temporary access token for the specified user. + // +admin – only to be used by installation admins + CreateTemporaryAccessToken(context.Context, *CreateTemporaryAccessTokenRequest) (*CreateTemporaryAccessTokenResponse, error) + mustEmbedUnimplementedTokenServiceServer() +} + +// UnimplementedTokenServiceServer must be embedded to have forward compatible implementations. +type UnimplementedTokenServiceServer struct { +} + +func (UnimplementedTokenServiceServer) CreateTemporaryAccessToken(context.Context, *CreateTemporaryAccessTokenRequest) (*CreateTemporaryAccessTokenResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method CreateTemporaryAccessToken not implemented") +} +func (UnimplementedTokenServiceServer) mustEmbedUnimplementedTokenServiceServer() {} + +// UnsafeTokenServiceServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to TokenServiceServer will +// result in compilation errors. +type UnsafeTokenServiceServer interface { + mustEmbedUnimplementedTokenServiceServer() +} + +func RegisterTokenServiceServer(s grpc.ServiceRegistrar, srv TokenServiceServer) { + s.RegisterService(&TokenService_ServiceDesc, srv) +} + +func _TokenService_CreateTemporaryAccessToken_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(CreateTemporaryAccessTokenRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(TokenServiceServer).CreateTemporaryAccessToken(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: "/gitpod.v1.TokenService/CreateTemporaryAccessToken", + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(TokenServiceServer).CreateTemporaryAccessToken(ctx, req.(*CreateTemporaryAccessTokenRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// TokenService_ServiceDesc is the grpc.ServiceDesc for TokenService service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var TokenService_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "gitpod.v1.TokenService", + HandlerType: (*TokenServiceServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "CreateTemporaryAccessToken", + Handler: _TokenService_CreateTemporaryAccessToken_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "gitpod/v1/token.proto", +} diff --git a/components/public-api/go/v1/v1connect/token.connect.go b/components/public-api/go/v1/v1connect/token.connect.go new file mode 100644 index 00000000000000..c99da8ed407c53 --- /dev/null +++ b/components/public-api/go/v1/v1connect/token.connect.go @@ -0,0 +1,94 @@ +// Copyright (c) 2024 Gitpod GmbH. All rights reserved. +// Licensed under the GNU Affero General Public License (AGPL). +// See License.AGPL.txt in the project root for license information. + +// Code generated by protoc-gen-connect-go. DO NOT EDIT. +// +// Source: gitpod/v1/token.proto + +package v1connect + +import ( + context "context" + errors "errors" + connect_go "github.com/bufbuild/connect-go" + v1 "github.com/gitpod-io/gitpod/components/public-api/go/v1" + http "net/http" + strings "strings" +) + +// This is a compile-time assertion to ensure that this generated file and the connect package are +// compatible. If you get a compiler error that this constant is not defined, this code was +// generated with a version of connect newer than the one compiled into your binary. You can fix the +// problem by either regenerating this code with an older version of connect or updating the connect +// version compiled into your binary. +const _ = connect_go.IsAtLeastVersion0_1_0 + +const ( + // TokenServiceName is the fully-qualified name of the TokenService service. + TokenServiceName = "gitpod.v1.TokenService" +) + +// TokenServiceClient is a client for the gitpod.v1.TokenService service. +type TokenServiceClient interface { + // CreateUserToken creates a new temporary access token for the specified user. + // +admin – only to be used by installation admins + CreateTemporaryAccessToken(context.Context, *connect_go.Request[v1.CreateTemporaryAccessTokenRequest]) (*connect_go.Response[v1.CreateTemporaryAccessTokenResponse], error) +} + +// NewTokenServiceClient constructs a client for the gitpod.v1.TokenService service. By default, it +// uses the Connect protocol with the binary Protobuf Codec, asks for gzipped responses, and sends +// uncompressed requests. To use the gRPC or gRPC-Web protocols, supply the connect.WithGRPC() or +// connect.WithGRPCWeb() options. +// +// The URL supplied here should be the base URL for the Connect or gRPC server (for example, +// http://api.acme.com or https://acme.com/grpc). +func NewTokenServiceClient(httpClient connect_go.HTTPClient, baseURL string, opts ...connect_go.ClientOption) TokenServiceClient { + baseURL = strings.TrimRight(baseURL, "/") + return &tokenServiceClient{ + createTemporaryAccessToken: connect_go.NewClient[v1.CreateTemporaryAccessTokenRequest, v1.CreateTemporaryAccessTokenResponse]( + httpClient, + baseURL+"/gitpod.v1.TokenService/CreateTemporaryAccessToken", + opts..., + ), + } +} + +// tokenServiceClient implements TokenServiceClient. +type tokenServiceClient struct { + createTemporaryAccessToken *connect_go.Client[v1.CreateTemporaryAccessTokenRequest, v1.CreateTemporaryAccessTokenResponse] +} + +// CreateTemporaryAccessToken calls gitpod.v1.TokenService.CreateTemporaryAccessToken. +func (c *tokenServiceClient) CreateTemporaryAccessToken(ctx context.Context, req *connect_go.Request[v1.CreateTemporaryAccessTokenRequest]) (*connect_go.Response[v1.CreateTemporaryAccessTokenResponse], error) { + return c.createTemporaryAccessToken.CallUnary(ctx, req) +} + +// TokenServiceHandler is an implementation of the gitpod.v1.TokenService service. +type TokenServiceHandler interface { + // CreateUserToken creates a new temporary access token for the specified user. + // +admin – only to be used by installation admins + CreateTemporaryAccessToken(context.Context, *connect_go.Request[v1.CreateTemporaryAccessTokenRequest]) (*connect_go.Response[v1.CreateTemporaryAccessTokenResponse], error) +} + +// NewTokenServiceHandler builds an HTTP handler from the service implementation. It returns the +// path on which to mount the handler and the handler itself. +// +// By default, handlers support the Connect, gRPC, and gRPC-Web protocols with the binary Protobuf +// and JSON codecs. They also support gzip compression. +func NewTokenServiceHandler(svc TokenServiceHandler, opts ...connect_go.HandlerOption) (string, http.Handler) { + mux := http.NewServeMux() + mux.Handle("/gitpod.v1.TokenService/CreateTemporaryAccessToken", connect_go.NewUnaryHandler( + "/gitpod.v1.TokenService/CreateTemporaryAccessToken", + svc.CreateTemporaryAccessToken, + opts..., + )) + return "/gitpod.v1.TokenService/", mux +} + +// UnimplementedTokenServiceHandler returns CodeUnimplemented from all methods. +type UnimplementedTokenServiceHandler struct{} + +func (UnimplementedTokenServiceHandler) CreateTemporaryAccessToken(context.Context, *connect_go.Request[v1.CreateTemporaryAccessTokenRequest]) (*connect_go.Response[v1.CreateTemporaryAccessTokenResponse], error) { + return nil, connect_go.NewError(connect_go.CodeUnimplemented, errors.New("gitpod.v1.TokenService.CreateTemporaryAccessToken is not implemented")) +} diff --git a/components/public-api/go/v1/v1connect/token.proxy.connect.go b/components/public-api/go/v1/v1connect/token.proxy.connect.go new file mode 100644 index 00000000000000..2417dd629d33e5 --- /dev/null +++ b/components/public-api/go/v1/v1connect/token.proxy.connect.go @@ -0,0 +1,30 @@ +// Copyright (c) 2024 Gitpod GmbH. All rights reserved. +// Licensed under the GNU Affero General Public License (AGPL). +// See License.AGPL.txt in the project root for license information. + +// Code generated by protoc-proxy-gen. DO NOT EDIT. + +package v1connect + +import ( + context "context" + connect_go "github.com/bufbuild/connect-go" + v1 "github.com/gitpod-io/gitpod/components/public-api/go/v1" +) + +var _ TokenServiceHandler = (*ProxyTokenServiceHandler)(nil) + +type ProxyTokenServiceHandler struct { + Client v1.TokenServiceClient + UnimplementedTokenServiceHandler +} + +func (s *ProxyTokenServiceHandler) CreateTemporaryAccessToken(ctx context.Context, req *connect_go.Request[v1.CreateTemporaryAccessTokenRequest]) (*connect_go.Response[v1.CreateTemporaryAccessTokenResponse], error) { + resp, err := s.Client.CreateTemporaryAccessToken(ctx, req.Msg) + if err != nil { + // TODO(milan): Convert to correct status code + return nil, err + } + + return connect_go.NewResponse(resp), nil +} diff --git a/components/public-api/typescript/src/gitpod/v1/token_connect.ts b/components/public-api/typescript/src/gitpod/v1/token_connect.ts new file mode 100644 index 00000000000000..f06a5c128143be --- /dev/null +++ b/components/public-api/typescript/src/gitpod/v1/token_connect.ts @@ -0,0 +1,34 @@ +/** + * Copyright (c) 2024 Gitpod GmbH. All rights reserved. + * Licensed under the GNU Affero General Public License (AGPL). + * See License.AGPL.txt in the project root for license information. + */ + +// @generated by protoc-gen-connect-es v1.1.2 with parameter "target=ts" +// @generated from file gitpod/v1/token.proto (package gitpod.v1, syntax proto3) +/* eslint-disable */ +// @ts-nocheck + +import { CreateTemporaryAccessTokenRequest, CreateTemporaryAccessTokenResponse } from "./token_pb.js"; +import { MethodKind } from "@bufbuild/protobuf"; + +/** + * @generated from service gitpod.v1.TokenService + */ +export const TokenService = { + typeName: "gitpod.v1.TokenService", + methods: { + /** + * CreateUserToken creates a new temporary access token for the specified user. + * +admin – only to be used by installation admins + * + * @generated from rpc gitpod.v1.TokenService.CreateTemporaryAccessToken + */ + createTemporaryAccessToken: { + name: "CreateTemporaryAccessToken", + I: CreateTemporaryAccessTokenRequest, + O: CreateTemporaryAccessTokenResponse, + kind: MethodKind.Unary, + }, + } +} as const; diff --git a/components/public-api/typescript/src/gitpod/v1/token_pb.ts b/components/public-api/typescript/src/gitpod/v1/token_pb.ts new file mode 100644 index 00000000000000..f8b89c4244dbdd --- /dev/null +++ b/components/public-api/typescript/src/gitpod/v1/token_pb.ts @@ -0,0 +1,106 @@ +/** + * Copyright (c) 2024 Gitpod GmbH. All rights reserved. + * Licensed under the GNU Affero General Public License (AGPL). + * See License.AGPL.txt in the project root for license information. + */ + +// @generated by protoc-gen-es v1.3.3 with parameter "target=ts" +// @generated from file gitpod/v1/token.proto (package gitpod.v1, syntax proto3) +/* eslint-disable */ +// @ts-nocheck + +import type { BinaryReadOptions, FieldList, JsonReadOptions, JsonValue, PartialMessage, PlainMessage } from "@bufbuild/protobuf"; +import { Message, proto3 } from "@bufbuild/protobuf"; + +/** + * @generated from message gitpod.v1.CreateTemporaryAccessTokenRequest + */ +export class CreateTemporaryAccessTokenRequest extends Message { + /** + * user_id is the identifier of the user for which the token is created. + * + * @generated from field: string user_id = 1; + */ + userId = ""; + + /** + * expiry_seconds is the number of seconds the token is valid for. + * value should in the range [1, 600] + * + * @generated from field: int32 expiry_seconds = 2; + */ + expirySeconds = 0; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "gitpod.v1.CreateTemporaryAccessTokenRequest"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "user_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 2, name: "expiry_seconds", kind: "scalar", T: 5 /* ScalarType.INT32 */ }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): CreateTemporaryAccessTokenRequest { + return new CreateTemporaryAccessTokenRequest().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): CreateTemporaryAccessTokenRequest { + return new CreateTemporaryAccessTokenRequest().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): CreateTemporaryAccessTokenRequest { + return new CreateTemporaryAccessTokenRequest().fromJsonString(jsonString, options); + } + + static equals(a: CreateTemporaryAccessTokenRequest | PlainMessage | undefined, b: CreateTemporaryAccessTokenRequest | PlainMessage | undefined): boolean { + return proto3.util.equals(CreateTemporaryAccessTokenRequest, a, b); + } +} + +/** + * @generated from message gitpod.v1.CreateTemporaryAccessTokenResponse + */ +export class CreateTemporaryAccessTokenResponse extends Message { + /** + * cookie_name is the name of the cookie to use for the token. + * + * @generated from field: string cookie_name = 1; + */ + cookieName = ""; + + /** + * @generated from field: string token = 2; + */ + token = ""; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "gitpod.v1.CreateTemporaryAccessTokenResponse"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "cookie_name", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 2, name: "token", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): CreateTemporaryAccessTokenResponse { + return new CreateTemporaryAccessTokenResponse().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): CreateTemporaryAccessTokenResponse { + return new CreateTemporaryAccessTokenResponse().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): CreateTemporaryAccessTokenResponse { + return new CreateTemporaryAccessTokenResponse().fromJsonString(jsonString, options); + } + + static equals(a: CreateTemporaryAccessTokenResponse | PlainMessage | undefined, b: CreateTemporaryAccessTokenResponse | PlainMessage | undefined): boolean { + return proto3.util.equals(CreateTemporaryAccessTokenResponse, a, b); + } +} diff --git a/components/server/src/api/server.ts b/components/server/src/api/server.ts index de42e904b1b8c4..b8fef8e30b18e5 100644 --- a/components/server/src/api/server.ts +++ b/components/server/src/api/server.ts @@ -61,6 +61,8 @@ import { UserService as UserServiceInternal } from "../user/user-service"; import { InstallationServiceAPI } from "./installation-service-api"; import { InstallationService } from "@gitpod/public-api/lib/gitpod/v1/installation_connect"; import { RateLimitter } from "../rate-limitter"; +import { TokenServiceAPI } from "./token-service-api"; +import { TokenService } from "@gitpod/public-api/lib/gitpod/v1/token_connect"; decorate(injectable(), PublicAPIConverter); @@ -74,6 +76,7 @@ export class API { @inject(TeamsServiceAPI) private readonly teamServiceApi: TeamsServiceAPI; @inject(WorkspaceServiceAPI) private readonly workspaceServiceApi: WorkspaceServiceAPI; @inject(OrganizationServiceAPI) private readonly organizationServiceApi: OrganizationServiceAPI; + @inject(TokenServiceAPI) private readonly tokenServiceAPI: TokenServiceAPI; @inject(ConfigurationServiceAPI) private readonly configurationServiceApi: ConfigurationServiceAPI; @inject(AuthProviderServiceAPI) private readonly authProviderServiceApi: AuthProviderServiceAPI; @inject(EnvironmentVariableServiceAPI) private readonly envvarServiceApi: EnvironmentVariableServiceAPI; @@ -133,6 +136,7 @@ export class API { service(UserService, this.userServiceApi), service(WorkspaceService, this.workspaceServiceApi), service(OrganizationService, this.organizationServiceApi), + service(TokenService, this.tokenServiceAPI), service(ConfigurationService, this.configurationServiceApi), service(AuthProviderService, this.authProviderServiceApi), service(EnvironmentVariableService, this.envvarServiceApi), @@ -367,6 +371,7 @@ export class API { bind(TeamsServiceAPI).toSelf().inSingletonScope(); bind(WorkspaceServiceAPI).toSelf().inSingletonScope(); bind(OrganizationServiceAPI).toSelf().inSingletonScope(); + bind(TokenServiceAPI).toSelf().inSingletonScope(); bind(ConfigurationServiceAPI).toSelf().inSingletonScope(); bind(AuthProviderServiceAPI).toSelf().inSingletonScope(); bind(EnvironmentVariableServiceAPI).toSelf().inSingletonScope(); diff --git a/components/server/src/api/token-service-api.ts b/components/server/src/api/token-service-api.ts new file mode 100644 index 00000000000000..c1af31b1615a1a --- /dev/null +++ b/components/server/src/api/token-service-api.ts @@ -0,0 +1,69 @@ +/** + * Copyright (c) 2024 Gitpod GmbH. All rights reserved. + * Licensed under the GNU Affero General Public License (AGPL). + * See License.AGPL.txt in the project root for license information. + */ + +import { ServiceImpl } from "@connectrpc/connect"; +import { HandlerContext } from "@connectrpc/connect/dist/cjs/implementation"; +import { TokenService as TokenServiceInterface } from "@gitpod/public-api/lib/gitpod/v1/token_connect"; +import { + CreateTemporaryAccessTokenRequest, + CreateTemporaryAccessTokenResponse, +} from "@gitpod/public-api/lib/gitpod/v1/token_pb"; +import { inject, injectable } from "inversify"; +import { SessionHandler } from "../session-handler"; +import { ctxUserId } from "../util/request-context"; +import { Authorizer } from "../authorization/authorizer"; +import { validate as uuidValidate } from "uuid"; +import { ApplicationError, ErrorCodes } from "@gitpod/gitpod-protocol/lib/messaging/error"; +import { getExperimentsClientForBackend } from "@gitpod/gitpod-protocol/lib/experiments/configcat-server"; +import { UserDB } from "@gitpod/gitpod-db/lib"; +import { log } from "@gitpod/gitpod-protocol/lib/util/logging"; + +@injectable() +export class TokenServiceAPI implements ServiceImpl { + @inject(SessionHandler) private readonly session: SessionHandler; + @inject(Authorizer) private readonly auth: Authorizer; + @inject(UserDB) private readonly userDB: UserDB; + + async createTemporaryAccessToken( + req: CreateTemporaryAccessTokenRequest, + _: HandlerContext, + ): Promise { + const isDataOps = await getExperimentsClientForBackend().getValueAsync("dataops", false, {}); + if (!isDataOps) { + throw new ApplicationError(ErrorCodes.UNIMPLEMENTED, "not implemented"); + } + if (!uuidValidate(req.userId)) { + throw new ApplicationError(ErrorCodes.BAD_REQUEST, "userId is required"); + } + if (!req.expirySeconds || req.expirySeconds < 0 || req.expirySeconds > 600) { + throw new ApplicationError(ErrorCodes.BAD_REQUEST, "expirySeconds must be between 0 and 600"); + } + const ctxUserID = ctxUserId(); + await this.auth.checkPermissionOnUser(ctxUserID, "write_temporary_token", req.userId); + + // Double check if target user is an organization owned user + const targetUser = await this.userDB.findUserById(req.userId); + if (!targetUser) { + throw new ApplicationError(ErrorCodes.NOT_FOUND, `User '${req.userId}' not found`); + } + if (!targetUser.organizationId) { + throw new ApplicationError( + ErrorCodes.PERMISSION_DENIED, + `You do not have write_temporary_token on user ${req.userId}`, + ); + } + + const newToken = await this.session.createJWTSessionCookie(req.userId, { + expirySeconds: req.expirySeconds, + }); + log.info("Temporary access token created", { targetUser: req.userId }); + + return new CreateTemporaryAccessTokenResponse({ + cookieName: newToken.name, + token: newToken.value, + }); + } +} diff --git a/components/server/src/authorization/definitions.ts b/components/server/src/authorization/definitions.ts index 0c933dc23d92d2..bfbf8c611bd0aa 100644 --- a/components/server/src/authorization/definitions.ts +++ b/components/server/src/authorization/definitions.ts @@ -44,6 +44,7 @@ export type UserPermission = | "write_tokens" | "read_env_var" | "write_env_var" + | "write_temporary_token" | "code_sync"; export type InstallationResourceType = "installation"; diff --git a/components/spicedb/schema/schema.yaml b/components/spicedb/schema/schema.yaml index d1e76b038434b9..aab88dd07d9efc 100644 --- a/components/spicedb/schema/schema.yaml +++ b/components/spicedb/schema/schema.yaml @@ -29,6 +29,9 @@ schema: |- permission read_env_var = self permission write_env_var = self + // only used in specified cell, check EXP-1084 + permission write_temporary_token = installation->admin + organization->installation_admin + permission code_sync = self } @@ -240,6 +243,8 @@ assertions: - workspace:workspace_2_shared#access@user:user_1 # stranger can access other's workspaces - workspace:workspace_2_shared#access@user:user_2 + # installation admin can create temp token + - user:user_0#write_temporary_token@user:user_admin assertFalse: # user 10 cannot access project_1 - project:project_1#read_info@user:user_10 @@ -264,3 +269,5 @@ assertions: - project:project_2#write_info@user:user_2 - project:project_2#delete@user:user_2 - organization:org_2#read_billing@user:user_2 + # org owner cant write_temporary_token, because they are not installation admin + - user:user_1#write_temporary_token@user:user_0 diff --git a/test/tests/smoke-test/collaborator_test.go b/test/tests/smoke-test/collaborator_test.go index cabb58e03f3a44..d27728fe499447 100644 --- a/test/tests/smoke-test/collaborator_test.go +++ b/test/tests/smoke-test/collaborator_test.go @@ -66,8 +66,9 @@ func TestMembers(t *testing.T) { gitpodHost, _ := os.LookupEnv("GITPOD_HOST") orgID, _ := os.LookupEnv("ORG_ID") - if _, err := connectToServer(gitpodHost, userToken); err != nil { - t.Errorf("failed getting server conn: %v", err) + serverConn, err0 := connectToServer(gitpodHost, userToken) + if err0 != nil { + t.Errorf("failed getting server conn: %v", err0) } v1Http, v1Opts, v1Host := getPAPIConnSettings(gitpodHost, userToken, getUseCookie(), false) ev1Http, ev1Opts, ev1Host := getPAPIConnSettings(gitpodHost, userToken, getUseCookie(), true) @@ -112,8 +113,9 @@ func TestProjects(t *testing.T) { gitpodHost, _ := os.LookupEnv("GITPOD_HOST") orgID, _ := os.LookupEnv("ORG_ID") - if _, err := connectToServer(gitpodHost, userToken); err != nil { - t.Errorf("failed getting server conn: %v", err) + serverConn, err0 := connectToServer(gitpodHost, userToken) + if err0 != nil { + t.Errorf("failed getting server conn: %v", err0) } v1Http, v1Opts, v1Host := getPAPIConnSettings(gitpodHost, userToken, getUseCookie(), false) ev1Http, ev1Opts, ev1Host := getPAPIConnSettings(gitpodHost, userToken, getUseCookie(), false) @@ -181,11 +183,9 @@ func TestGetProject(t *testing.T) { // Note: there's no usage endpoint in the server / public v1 experimental.v1 APIs -var serverConn *serverapi.APIoverJSONRPC - func shouldTestCollaborator() bool { should, _ := os.LookupEnv("TEST_COLLABORATOR") - return should != "true" + return should == "true" } func getUseCookie() bool { @@ -194,9 +194,6 @@ func getUseCookie() bool { } func connectToServer(gitpodHost, token string) (*serverapi.APIoverJSONRPC, error) { - if serverConn != nil { - return serverConn, nil - } supervisorConn, err := grpc.Dial(util.GetSupervisorAddress(), grpc.WithTransportCredentials(insecure.NewCredentials())) if err != nil { return nil, xerrors.Errorf("failed connecting to supervisor: %w", err) @@ -225,7 +222,6 @@ func connectToServer(gitpodHost, token string) (*serverapi.APIoverJSONRPC, error if err != nil { return nil, xerrors.Errorf("failed connecting to server: %w", err) } - serverConn = client return client, nil } diff --git a/test/tests/smoke-test/papi_create_temp_token_test.go b/test/tests/smoke-test/papi_create_temp_token_test.go new file mode 100644 index 00000000000000..0c078ee3e7dce0 --- /dev/null +++ b/test/tests/smoke-test/papi_create_temp_token_test.go @@ -0,0 +1,256 @@ +// Copyright (c) 2024 Gitpod GmbH. All rights reserved. +// Licensed under the GNU Affero General Public License (AGPL). +// See License.AGPL.txt in the project root for license information. + +package smoketest + +import ( + "context" + "fmt" + "os" + "strings" + "testing" + "time" + + connect "github.com/bufbuild/connect-go" + v1 "github.com/gitpod-io/gitpod/components/public-api/go/v1" + v1connect "github.com/gitpod-io/gitpod/components/public-api/go/v1/v1connect" +) + +/* +* +export TEST_CREATE_TMP_TOKEN=true +export GITPOD_HOST=hw-token-exp-1084.preview.gitpod-dev.com + + export INSTALLATION_ADMIN_PAT= + # PAT of a member or an owner or a collaborator + export MEMBER_USER_PAT= + +export MEMBER_USER_ID=fffbc8e0-7f70-4afc-a370-63c889f7e644 +export TARGET_USER_ID=fffbc8e0-7f70-4afc-a370-63c889f7e644 + +go test -run "^TestCreateTemporaryAccessToken" github.com/gitpod-io/gitpod/test/tests/smoke-test -v -count=1 +*/ +const BUILTIN_INSTLLATION_ADMIN_USER_ID = "f071bb8e-b5d1-46cf-a436-da03ae63bcd2" + +func TestCreateTemporaryAccessToken(t *testing.T) { + if !shouldTestPAPICreateTmpToken() { + t.Skip("skip papi create temporary access token test") + return + } + gitpodHost, _ := os.LookupEnv("GITPOD_HOST") + adminPAT, _ := os.LookupEnv("INSTALLATION_ADMIN_PAT") + targetUserID, _ := os.LookupEnv("TARGET_USER_ID") + ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) + defer cancel() + + assertGetUser(ctx, t, gitpodHost, adminPAT, BUILTIN_INSTLLATION_ADMIN_USER_ID, "") + newTargetToken := assertTemporaryAccessToken(ctx, t, gitpodHost, adminPAT, targetUserID, 60, "") + if newTargetToken == "" { + return + } + + assertGetUser(ctx, t, gitpodHost, adminPAT, BUILTIN_INSTLLATION_ADMIN_USER_ID, "") + assertGetUser(ctx, t, gitpodHost, newTargetToken, targetUserID, "") +} + +func TestCreateTemporaryAccessTokenDeniedToCreateInstallationAdmin(t *testing.T) { + // because installation admin is not an organization owned user + if !shouldTestPAPICreateTmpToken() { + t.Skip("skip papi create temporary access token test") + return + } + gitpodHost, _ := os.LookupEnv("GITPOD_HOST") + adminPAT, _ := os.LookupEnv("INSTALLATION_ADMIN_PAT") + ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) + defer cancel() + + assertGetUser(ctx, t, gitpodHost, adminPAT, BUILTIN_INSTLLATION_ADMIN_USER_ID, "") + assertTemporaryAccessToken(ctx, t, gitpodHost, adminPAT, BUILTIN_INSTLLATION_ADMIN_USER_ID, 60, "permission_denied") +} + +func TestCreateTemporaryAccessTokenWithNotFoundUser(t *testing.T) { + if !shouldTestPAPICreateTmpToken() { + t.Skip("skip papi create temporary access token test") + return + } + gitpodHost, _ := os.LookupEnv("GITPOD_HOST") + adminPAT, _ := os.LookupEnv("INSTALLATION_ADMIN_PAT") + ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) + defer cancel() + + assertGetUser(ctx, t, gitpodHost, adminPAT, BUILTIN_INSTLLATION_ADMIN_USER_ID, "") + assertTemporaryAccessToken(ctx, t, gitpodHost, adminPAT, "00000000-0000-0000-0000-000000000000", 60, "not_found") +} + +func TestCreateTemporaryAccessTokenViaMember(t *testing.T) { + if !shouldTestPAPICreateTmpToken() { + t.Skip("skip papi create temporary access token test") + return + } + gitpodHost, _ := os.LookupEnv("GITPOD_HOST") + memberUserPAT, _ := os.LookupEnv("MEMBER_USER_PAT") + memberUserID, _ := os.LookupEnv("MEMBER_USER_ID") + targetUserID, _ := os.LookupEnv("TARGET_USER_ID") + ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) + defer cancel() + + assertGetUser(ctx, t, gitpodHost, memberUserPAT, memberUserID, "") + assertTemporaryAccessToken(ctx, t, gitpodHost, memberUserPAT, targetUserID, 60, "permission_denied") +} + +func TestCreateTemporaryAccessTokenExpiry(t *testing.T) { + if !shouldTestPAPICreateTmpToken() { + t.Skip("skip papi create temporary access token test") + return + } + gitpodHost, _ := os.LookupEnv("GITPOD_HOST") + adminPAT, _ := os.LookupEnv("INSTALLATION_ADMIN_PAT") + targetUserID, _ := os.LookupEnv("TARGET_USER_ID") + ctx, cancel := context.WithTimeout(context.Background(), time.Second*10) + defer cancel() + + assertGetUser(ctx, t, gitpodHost, adminPAT, BUILTIN_INSTLLATION_ADMIN_USER_ID, "") + newTargetToken := assertTemporaryAccessToken(ctx, t, gitpodHost, adminPAT, targetUserID, 3, "") + if newTargetToken == "" { + return + } + assertGetUser(ctx, t, gitpodHost, adminPAT, BUILTIN_INSTLLATION_ADMIN_USER_ID, "") + assertGetUser(ctx, t, gitpodHost, newTargetToken, targetUserID, "") + + time.Sleep(time.Second * 3) + + assertGetUser(ctx, t, gitpodHost, newTargetToken, targetUserID, "unauthenticated") +} + +func TestCreateTemporaryAccessTokenCreateEnv(t *testing.T) { + if !shouldTestPAPICreateTmpToken() { + t.Skip("skip papi create temporary access token test") + return + } + gitpodHost, _ := os.LookupEnv("GITPOD_HOST") + adminPAT, _ := os.LookupEnv("INSTALLATION_ADMIN_PAT") + targetUserID, _ := os.LookupEnv("TARGET_USER_ID") + ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) + defer cancel() + + assertGetUser(ctx, t, gitpodHost, adminPAT, BUILTIN_INSTLLATION_ADMIN_USER_ID, "") + newTargetToken := assertTemporaryAccessToken(ctx, t, gitpodHost, adminPAT, targetUserID, 60, "") + if newTargetToken == "" { + return + } + + assertGetUser(ctx, t, gitpodHost, adminPAT, BUILTIN_INSTLLATION_ADMIN_USER_ID, "") + assertGetUser(ctx, t, gitpodHost, newTargetToken, targetUserID, "") + + assertCreateEnvVar(ctx, t, gitpodHost, newTargetToken, "foo", "boo") +} + +func assertTemporaryAccessToken(ctx context.Context, t *testing.T, gitpodHost, userToken, targetUserID string, expirySeconds int32, wantedErrMsg string) string { + useCookie := !strings.HasPrefix(userToken, "gitpod_pat_") + v1Http, v1Opts, v1Host := getPAPIConnSettings(gitpodHost, userToken, useCookie, false) + v1Client := v1connect.NewTokenServiceClient(v1Http, v1Host, v1Opts...) + targetInfo, err := v1Client.CreateTemporaryAccessToken(ctx, connect.NewRequest(&v1.CreateTemporaryAccessTokenRequest{ + UserId: targetUserID, + ExpirySeconds: expirySeconds, + })) + if wantedErrMsg != "" { + if err == nil { + t.Errorf("CreateTemporaryAccessToken() error = %v", err) + } + if !strings.Contains(err.Error(), wantedErrMsg) { + t.Errorf("CreateTemporaryAccessToken() error = %v, wantErr %v", err, wantedErrMsg) + } + return "" + } + if err != nil && wantedErrMsg == "" { + t.Errorf("CreateTemporaryAccessToken() error = %v", err) + return "" + } + return fmt.Sprintf("%s=%s", targetInfo.Msg.CookieName, targetInfo.Msg.Token) +} + +func assertGetUser(ctx context.Context, t *testing.T, gitpodHost, userToken string, wantedUser, wantedErrMsg string) { + useCookie := !strings.HasPrefix(userToken, "gitpod_pat_") + v1Http, v1Opts, v1Host := getPAPIConnSettings(gitpodHost, userToken, useCookie, false) + v1Client := v1connect.NewUserServiceClient(v1Http, v1Host, v1Opts...) + user, err := v1Client.GetAuthenticatedUser(ctx, connect.NewRequest(&v1.GetAuthenticatedUserRequest{})) + if wantedErrMsg != "" { + if err == nil { + t.Errorf("GetAuthenticatedUser() error = nil, wantErr %s", wantedErrMsg) + return + } + if !strings.Contains(err.Error(), wantedErrMsg) { + t.Errorf("GetAuthenticatedUser() error = %v, wantErr %s", err, wantedErrMsg) + } + return + } + if err != nil { + t.Errorf("GetAuthenticatedUser() error = %v", err) + return + } + if user.Msg.User.Id != wantedUser { + t.Errorf("GetAuthenticatedUser() = %v, wantUser %v", user.Msg.User.Id, wantedUser) + } +} + +func assertCreateEnvVar(ctx context.Context, t *testing.T, gitpodHost, userToken string, envVarName, envVarVal string) { + useCookie := !strings.HasPrefix(userToken, "gitpod_pat_") + v1Http, v1Opts, v1Host := getPAPIConnSettings(gitpodHost, userToken, useCookie, false) + v1Client := v1connect.NewEnvironmentVariableServiceClient(v1Http, v1Host, v1Opts...) + + list, err := v1Client.ListUserEnvironmentVariables(ctx, connect.NewRequest(&v1.ListUserEnvironmentVariablesRequest{})) + if err != nil { + t.Errorf("ListUserEnvironmentVariables() error = %v", err) + return + } + var found *v1.UserEnvironmentVariable = nil + for _, envVar := range list.Msg.EnvironmentVariables { + if envVar.Name == envVarName { + found = envVar + break + } + } + if found != nil { + fmt.Printf("found env var %+v\n", found) + } + if found == nil { + _, err := v1Client.CreateUserEnvironmentVariable(ctx, connect.NewRequest(&v1.CreateUserEnvironmentVariableRequest{ + Name: envVarName, + Value: envVarVal, + RepositoryPattern: "*/*", + })) + if err != nil { + t.Errorf("CreateUserEnvironmentVariable() error = %v", err) + return + } + } else { + scope := "*/*" + _, err := v1Client.UpdateUserEnvironmentVariable(ctx, connect.NewRequest(&v1.UpdateUserEnvironmentVariableRequest{ + EnvironmentVariableId: found.Id, + Name: &envVarName, + Value: &envVarVal, + RepositoryPattern: &scope, + })) + if err != nil { + t.Errorf("UpdateUserEnvironmentVariable() error = %v", err) + return + } + } + list2, err := v1Client.ListUserEnvironmentVariables(ctx, connect.NewRequest(&v1.ListUserEnvironmentVariablesRequest{})) + if err != nil { + t.Errorf("ListUserEnvironmentVariables() error = %v", err) + return + } + for _, envVar := range list2.Msg.EnvironmentVariables { + if envVar.Name == envVarName && envVar.Value == envVarVal { + return + } + } + t.Errorf("Cannot found env var %s=%s", envVarName, envVarVal) +} + +func shouldTestPAPICreateTmpToken() bool { + should, _ := os.LookupEnv("TEST_CREATE_TMP_TOKEN") + return should == "true" +} From d286eddb034dd42bf297ecf62688b8f1246db55f Mon Sep 17 00:00:00 2001 From: Huiwen Huang Date: Fri, 19 Jan 2024 07:33:09 +0000 Subject: [PATCH 2/4] Address feedback --- components/server/src/api/token-service-api.ts | 16 +--------------- components/spicedb/schema/schema.yaml | 2 +- 2 files changed, 2 insertions(+), 16 deletions(-) diff --git a/components/server/src/api/token-service-api.ts b/components/server/src/api/token-service-api.ts index c1af31b1615a1a..fff9aad57d71e6 100644 --- a/components/server/src/api/token-service-api.ts +++ b/components/server/src/api/token-service-api.ts @@ -18,14 +18,12 @@ import { Authorizer } from "../authorization/authorizer"; import { validate as uuidValidate } from "uuid"; import { ApplicationError, ErrorCodes } from "@gitpod/gitpod-protocol/lib/messaging/error"; import { getExperimentsClientForBackend } from "@gitpod/gitpod-protocol/lib/experiments/configcat-server"; -import { UserDB } from "@gitpod/gitpod-db/lib"; import { log } from "@gitpod/gitpod-protocol/lib/util/logging"; @injectable() export class TokenServiceAPI implements ServiceImpl { @inject(SessionHandler) private readonly session: SessionHandler; @inject(Authorizer) private readonly auth: Authorizer; - @inject(UserDB) private readonly userDB: UserDB; async createTemporaryAccessToken( req: CreateTemporaryAccessTokenRequest, @@ -44,22 +42,10 @@ export class TokenServiceAPI implements ServiceImpladmin + organization->installation_admin + permission write_temporary_token = organization->installation_admin permission code_sync = self } From 225c31824271f69e5866ad1de42f19e56c1b8f4c Mon Sep 17 00:00:00 2001 From: Huiwen Huang Date: Fri, 19 Jan 2024 07:59:55 +0000 Subject: [PATCH 3/4] Fix spice test cases --- components/spicedb/schema/schema.yaml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/components/spicedb/schema/schema.yaml b/components/spicedb/schema/schema.yaml index d37eed0ebacd79..d664d0afe98846 100644 --- a/components/spicedb/schema/schema.yaml +++ b/components/spicedb/schema/schema.yaml @@ -157,6 +157,9 @@ relationships: |- organization:org_1#owner@user:user_0 organization:org_1#member@user:user_1 organization:org_1#member@user:user_2 + user:user_0#organization@organization:org_1 + user:user_1#organization@organization:org_1 + user:user_2#organization@organization:org_1 // org_1 has a project project:project_1#org@organization:org_1 @@ -170,6 +173,7 @@ relationships: |- organization:org_2#member@user:user_10 // user_2 is a collaborator of org_2 organization:org_2#collaborator@user:user_2 + user:user_10#organization@organization:org_2 // org_2 has a project project_2 project:project_2#org@organization:org_2 @@ -245,6 +249,8 @@ assertions: - workspace:workspace_2_shared#access@user:user_2 # installation admin can create temp token - user:user_0#write_temporary_token@user:user_admin + - user:user_1#write_temporary_token@user:user_admin + - user:user_2#write_temporary_token@user:user_admin assertFalse: # user 10 cannot access project_1 - project:project_1#read_info@user:user_10 @@ -271,3 +277,5 @@ assertions: - organization:org_2#read_billing@user:user_2 # org owner cant write_temporary_token, because they are not installation admin - user:user_1#write_temporary_token@user:user_0 + # org_2 is not belong to installation_0 + - user:user_10#write_temporary_token@user:user_admin From 76e0b3aa7c6e6a4ed3e27545caa03c01ca244ccb Mon Sep 17 00:00:00 2001 From: Huiwen Huang Date: Fri, 19 Jan 2024 08:13:31 +0000 Subject: [PATCH 4/4] fix db tests --- components/server/src/api/teams.spec.db.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/components/server/src/api/teams.spec.db.ts b/components/server/src/api/teams.spec.db.ts index cef5a36e4a882d..1f54e29549f899 100644 --- a/components/server/src/api/teams.spec.db.ts +++ b/components/server/src/api/teams.spec.db.ts @@ -37,6 +37,7 @@ import { PrebuildManager } from "../prebuilds/prebuild-manager"; import { VerificationService } from "../auth/verification-service"; import { InstallationService } from "../auth/installation-service"; import { RateLimitter } from "../rate-limitter"; +import { Authorizer } from "../authorization/authorizer"; const expect = chai.expect; @@ -71,6 +72,7 @@ export class APITeamsServiceSpec { this.container.bind(VerificationService).toConstantValue({} as VerificationService); this.container.bind(InstallationService).toConstantValue({} as InstallationService); this.container.bind(RateLimitter).toConstantValue({} as RateLimitter); + this.container.bind(Authorizer).toConstantValue({} as Authorizer); // Clean-up database const typeorm = testContainer.get(TypeORM);