diff --git a/api/gen/proto/go/teleport/scopes/joining/v1/service.pb.go b/api/gen/proto/go/teleport/scopes/joining/v1/service.pb.go index fe43342987372..01b1430db5191 100644 --- a/api/gen/proto/go/teleport/scopes/joining/v1/service.pb.go +++ b/api/gen/proto/go/teleport/scopes/joining/v1/service.pb.go @@ -131,14 +131,18 @@ func (x *GetScopedTokenResponse) GetToken() *ScopedToken { // ListScopedTokensRequest is the request to list scoped tokens. type ListScopedTokensRequest struct { state protoimpl.MessageState `protogen:"open.v1"` - // ResourceScope filters tokens by their resource scope if specified. + // Filter tokens by their resource scope. ResourceScope *v1.Filter `protobuf:"bytes,1,opt,name=resource_scope,json=resourceScope,proto3" json:"resource_scope,omitempty"` - // AssignedScope filters tokens by their assigned scope if specified. + // Filter tokens by their assigned scope. AssignedScope *v1.Filter `protobuf:"bytes,2,opt,name=assigned_scope,json=assignedScope,proto3" json:"assigned_scope,omitempty"` - // Cursor is the pagination cursor. + // The pagination cursor. Cursor string `protobuf:"bytes,3,opt,name=cursor,proto3" json:"cursor,omitempty"` - // Limit is the maximum number of results to return. - Limit uint32 `protobuf:"varint,4,opt,name=limit,proto3" json:"limit,omitempty"` + // The maximum number of results to return. + Limit uint32 `protobuf:"varint,4,opt,name=limit,proto3" json:"limit,omitempty"` + // Filter tokens that apply at least one of the provided roles. + Roles []string `protobuf:"bytes,5,rep,name=roles,proto3" json:"roles,omitempty"` + // Filter tokens that match all provided labels. + Labels map[string]string `protobuf:"bytes,6,rep,name=labels,proto3" json:"labels,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -201,6 +205,20 @@ func (x *ListScopedTokensRequest) GetLimit() uint32 { return 0 } +func (x *ListScopedTokensRequest) GetRoles() []string { + if x != nil { + return x.Roles + } + return nil +} + +func (x *ListScopedTokensRequest) GetLabels() map[string]string { + if x != nil { + return x.Labels + } + return nil +} + // ListScopedTokensResponse is the response to list scoped tokens. type ListScopedTokensResponse struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -540,12 +558,17 @@ const file_teleport_scopes_joining_v1_service_proto_rawDesc = "" + "\x15GetScopedTokenRequest\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\"W\n" + "\x16GetScopedTokenResponse\x12=\n" + - "\x05token\x18\x01 \x01(\v2'.teleport.scopes.joining.v1.ScopedTokenR\x05token\"\xcd\x01\n" + + "\x05token\x18\x01 \x01(\v2'.teleport.scopes.joining.v1.ScopedTokenR\x05token\"\xf7\x02\n" + "\x17ListScopedTokensRequest\x12A\n" + "\x0eresource_scope\x18\x01 \x01(\v2\x1a.teleport.scopes.v1.FilterR\rresourceScope\x12A\n" + "\x0eassigned_scope\x18\x02 \x01(\v2\x1a.teleport.scopes.v1.FilterR\rassignedScope\x12\x16\n" + "\x06cursor\x18\x03 \x01(\tR\x06cursor\x12\x14\n" + - "\x05limit\x18\x04 \x01(\rR\x05limit\"s\n" + + "\x05limit\x18\x04 \x01(\rR\x05limit\x12\x14\n" + + "\x05roles\x18\x05 \x03(\tR\x05roles\x12W\n" + + "\x06labels\x18\x06 \x03(\v2?.teleport.scopes.joining.v1.ListScopedTokensRequest.LabelsEntryR\x06labels\x1a9\n" + + "\vLabelsEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"s\n" + "\x18ListScopedTokensResponse\x12?\n" + "\x06tokens\x18\x01 \x03(\v2'.teleport.scopes.joining.v1.ScopedTokenR\x06tokens\x12\x16\n" + "\x06cursor\x18\x02 \x01(\tR\x06cursor\"Y\n" + @@ -580,7 +603,7 @@ func file_teleport_scopes_joining_v1_service_proto_rawDescGZIP() []byte { return file_teleport_scopes_joining_v1_service_proto_rawDescData } -var file_teleport_scopes_joining_v1_service_proto_msgTypes = make([]protoimpl.MessageInfo, 10) +var file_teleport_scopes_joining_v1_service_proto_msgTypes = make([]protoimpl.MessageInfo, 11) var file_teleport_scopes_joining_v1_service_proto_goTypes = []any{ (*GetScopedTokenRequest)(nil), // 0: teleport.scopes.joining.v1.GetScopedTokenRequest (*GetScopedTokenResponse)(nil), // 1: teleport.scopes.joining.v1.GetScopedTokenResponse @@ -592,33 +615,35 @@ var file_teleport_scopes_joining_v1_service_proto_goTypes = []any{ (*UpdateScopedTokenResponse)(nil), // 7: teleport.scopes.joining.v1.UpdateScopedTokenResponse (*DeleteScopedTokenRequest)(nil), // 8: teleport.scopes.joining.v1.DeleteScopedTokenRequest (*DeleteScopedTokenResponse)(nil), // 9: teleport.scopes.joining.v1.DeleteScopedTokenResponse - (*ScopedToken)(nil), // 10: teleport.scopes.joining.v1.ScopedToken - (*v1.Filter)(nil), // 11: teleport.scopes.v1.Filter + nil, // 10: teleport.scopes.joining.v1.ListScopedTokensRequest.LabelsEntry + (*ScopedToken)(nil), // 11: teleport.scopes.joining.v1.ScopedToken + (*v1.Filter)(nil), // 12: teleport.scopes.v1.Filter } var file_teleport_scopes_joining_v1_service_proto_depIdxs = []int32{ - 10, // 0: teleport.scopes.joining.v1.GetScopedTokenResponse.token:type_name -> teleport.scopes.joining.v1.ScopedToken - 11, // 1: teleport.scopes.joining.v1.ListScopedTokensRequest.resource_scope:type_name -> teleport.scopes.v1.Filter - 11, // 2: teleport.scopes.joining.v1.ListScopedTokensRequest.assigned_scope:type_name -> teleport.scopes.v1.Filter - 10, // 3: teleport.scopes.joining.v1.ListScopedTokensResponse.tokens:type_name -> teleport.scopes.joining.v1.ScopedToken - 10, // 4: teleport.scopes.joining.v1.CreateScopedTokenRequest.token:type_name -> teleport.scopes.joining.v1.ScopedToken - 10, // 5: teleport.scopes.joining.v1.CreateScopedTokenResponse.token:type_name -> teleport.scopes.joining.v1.ScopedToken - 10, // 6: teleport.scopes.joining.v1.UpdateScopedTokenRequest.token:type_name -> teleport.scopes.joining.v1.ScopedToken - 10, // 7: teleport.scopes.joining.v1.UpdateScopedTokenResponse.token:type_name -> teleport.scopes.joining.v1.ScopedToken - 0, // 8: teleport.scopes.joining.v1.ScopedJoiningService.GetScopedToken:input_type -> teleport.scopes.joining.v1.GetScopedTokenRequest - 2, // 9: teleport.scopes.joining.v1.ScopedJoiningService.ListScopedTokens:input_type -> teleport.scopes.joining.v1.ListScopedTokensRequest - 4, // 10: teleport.scopes.joining.v1.ScopedJoiningService.CreateScopedToken:input_type -> teleport.scopes.joining.v1.CreateScopedTokenRequest - 6, // 11: teleport.scopes.joining.v1.ScopedJoiningService.UpdateScopedToken:input_type -> teleport.scopes.joining.v1.UpdateScopedTokenRequest - 8, // 12: teleport.scopes.joining.v1.ScopedJoiningService.DeleteScopedToken:input_type -> teleport.scopes.joining.v1.DeleteScopedTokenRequest - 1, // 13: teleport.scopes.joining.v1.ScopedJoiningService.GetScopedToken:output_type -> teleport.scopes.joining.v1.GetScopedTokenResponse - 3, // 14: teleport.scopes.joining.v1.ScopedJoiningService.ListScopedTokens:output_type -> teleport.scopes.joining.v1.ListScopedTokensResponse - 5, // 15: teleport.scopes.joining.v1.ScopedJoiningService.CreateScopedToken:output_type -> teleport.scopes.joining.v1.CreateScopedTokenResponse - 7, // 16: teleport.scopes.joining.v1.ScopedJoiningService.UpdateScopedToken:output_type -> teleport.scopes.joining.v1.UpdateScopedTokenResponse - 9, // 17: teleport.scopes.joining.v1.ScopedJoiningService.DeleteScopedToken:output_type -> teleport.scopes.joining.v1.DeleteScopedTokenResponse - 13, // [13:18] is the sub-list for method output_type - 8, // [8:13] is the sub-list for method input_type - 8, // [8:8] is the sub-list for extension type_name - 8, // [8:8] is the sub-list for extension extendee - 0, // [0:8] is the sub-list for field type_name + 11, // 0: teleport.scopes.joining.v1.GetScopedTokenResponse.token:type_name -> teleport.scopes.joining.v1.ScopedToken + 12, // 1: teleport.scopes.joining.v1.ListScopedTokensRequest.resource_scope:type_name -> teleport.scopes.v1.Filter + 12, // 2: teleport.scopes.joining.v1.ListScopedTokensRequest.assigned_scope:type_name -> teleport.scopes.v1.Filter + 10, // 3: teleport.scopes.joining.v1.ListScopedTokensRequest.labels:type_name -> teleport.scopes.joining.v1.ListScopedTokensRequest.LabelsEntry + 11, // 4: teleport.scopes.joining.v1.ListScopedTokensResponse.tokens:type_name -> teleport.scopes.joining.v1.ScopedToken + 11, // 5: teleport.scopes.joining.v1.CreateScopedTokenRequest.token:type_name -> teleport.scopes.joining.v1.ScopedToken + 11, // 6: teleport.scopes.joining.v1.CreateScopedTokenResponse.token:type_name -> teleport.scopes.joining.v1.ScopedToken + 11, // 7: teleport.scopes.joining.v1.UpdateScopedTokenRequest.token:type_name -> teleport.scopes.joining.v1.ScopedToken + 11, // 8: teleport.scopes.joining.v1.UpdateScopedTokenResponse.token:type_name -> teleport.scopes.joining.v1.ScopedToken + 0, // 9: teleport.scopes.joining.v1.ScopedJoiningService.GetScopedToken:input_type -> teleport.scopes.joining.v1.GetScopedTokenRequest + 2, // 10: teleport.scopes.joining.v1.ScopedJoiningService.ListScopedTokens:input_type -> teleport.scopes.joining.v1.ListScopedTokensRequest + 4, // 11: teleport.scopes.joining.v1.ScopedJoiningService.CreateScopedToken:input_type -> teleport.scopes.joining.v1.CreateScopedTokenRequest + 6, // 12: teleport.scopes.joining.v1.ScopedJoiningService.UpdateScopedToken:input_type -> teleport.scopes.joining.v1.UpdateScopedTokenRequest + 8, // 13: teleport.scopes.joining.v1.ScopedJoiningService.DeleteScopedToken:input_type -> teleport.scopes.joining.v1.DeleteScopedTokenRequest + 1, // 14: teleport.scopes.joining.v1.ScopedJoiningService.GetScopedToken:output_type -> teleport.scopes.joining.v1.GetScopedTokenResponse + 3, // 15: teleport.scopes.joining.v1.ScopedJoiningService.ListScopedTokens:output_type -> teleport.scopes.joining.v1.ListScopedTokensResponse + 5, // 16: teleport.scopes.joining.v1.ScopedJoiningService.CreateScopedToken:output_type -> teleport.scopes.joining.v1.CreateScopedTokenResponse + 7, // 17: teleport.scopes.joining.v1.ScopedJoiningService.UpdateScopedToken:output_type -> teleport.scopes.joining.v1.UpdateScopedTokenResponse + 9, // 18: teleport.scopes.joining.v1.ScopedJoiningService.DeleteScopedToken:output_type -> teleport.scopes.joining.v1.DeleteScopedTokenResponse + 14, // [14:19] is the sub-list for method output_type + 9, // [9:14] is the sub-list for method input_type + 9, // [9:9] is the sub-list for extension type_name + 9, // [9:9] is the sub-list for extension extendee + 0, // [0:9] is the sub-list for field type_name } func init() { file_teleport_scopes_joining_v1_service_proto_init() } @@ -633,7 +658,7 @@ func file_teleport_scopes_joining_v1_service_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_teleport_scopes_joining_v1_service_proto_rawDesc), len(file_teleport_scopes_joining_v1_service_proto_rawDesc)), NumEnums: 0, - NumMessages: 10, + NumMessages: 11, NumExtensions: 0, NumServices: 1, }, diff --git a/api/gen/proto/go/teleport/scopes/joining/v1/token.pb.go b/api/gen/proto/go/teleport/scopes/joining/v1/token.pb.go index 8b03ab9562531..caa8834da135e 100644 --- a/api/gen/proto/go/teleport/scopes/joining/v1/token.pb.go +++ b/api/gen/proto/go/teleport/scopes/joining/v1/token.pb.go @@ -133,8 +133,15 @@ func (x *ScopedToken) GetSpec() *ScopedTokenSpec { // ScopedTokenSpec is the specification of a scoped token. type ScopedTokenSpec struct { state protoimpl.MessageState `protogen:"open.v1"` - // AssignedScope is the scope to which this token is assigned. + // The scope to which this token is assigned. AssignedScope string `protobuf:"bytes,1,opt,name=assigned_scope,json=assignedScope,proto3" json:"assigned_scope,omitempty"` + // The list of roles associated with the token. They will be converted + // to metadata in the SSH and X509 certificates issued to the user of the + // token. + Roles []string `protobuf:"bytes,2,rep,name=roles,proto3" json:"roles,omitempty"` + // The joining method required in order to use this token. + // Supported joining methods for scoped tokens only include 'token'. + JoinMethod string `protobuf:"bytes,3,opt,name=join_method,json=joinMethod,proto3" json:"join_method,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -176,6 +183,20 @@ func (x *ScopedTokenSpec) GetAssignedScope() string { return "" } +func (x *ScopedTokenSpec) GetRoles() []string { + if x != nil { + return x.Roles + } + return nil +} + +func (x *ScopedTokenSpec) GetJoinMethod() string { + if x != nil { + return x.JoinMethod + } + return "" +} + var File_teleport_scopes_joining_v1_token_proto protoreflect.FileDescriptor const file_teleport_scopes_joining_v1_token_proto_rawDesc = "" + @@ -187,9 +208,12 @@ const file_teleport_scopes_joining_v1_token_proto_rawDesc = "" + "\aversion\x18\x03 \x01(\tR\aversion\x128\n" + "\bmetadata\x18\x04 \x01(\v2\x1c.teleport.header.v1.MetadataR\bmetadata\x12\x14\n" + "\x05scope\x18\x05 \x01(\tR\x05scope\x12?\n" + - "\x04spec\x18\x06 \x01(\v2+.teleport.scopes.joining.v1.ScopedTokenSpecR\x04spec\"8\n" + + "\x04spec\x18\x06 \x01(\v2+.teleport.scopes.joining.v1.ScopedTokenSpecR\x04spec\"o\n" + "\x0fScopedTokenSpec\x12%\n" + - "\x0eassigned_scope\x18\x01 \x01(\tR\rassignedScopeBYZWgithub.com/gravitational/teleport/api/gen/proto/go/teleport/scopes/joining/v1;joiningv1b\x06proto3" + "\x0eassigned_scope\x18\x01 \x01(\tR\rassignedScope\x12\x14\n" + + "\x05roles\x18\x02 \x03(\tR\x05roles\x12\x1f\n" + + "\vjoin_method\x18\x03 \x01(\tR\n" + + "joinMethodBYZWgithub.com/gravitational/teleport/api/gen/proto/go/teleport/scopes/joining/v1;joiningv1b\x06proto3" var ( file_teleport_scopes_joining_v1_token_proto_rawDescOnce sync.Once diff --git a/api/proto/teleport/scopes/joining/v1/service.proto b/api/proto/teleport/scopes/joining/v1/service.proto index f746f153db350..8d1311f24aee1 100644 --- a/api/proto/teleport/scopes/joining/v1/service.proto +++ b/api/proto/teleport/scopes/joining/v1/service.proto @@ -53,17 +53,23 @@ message GetScopedTokenResponse { // ListScopedTokensRequest is the request to list scoped tokens. message ListScopedTokensRequest { - // ResourceScope filters tokens by their resource scope if specified. + // Filter tokens by their resource scope. teleport.scopes.v1.Filter resource_scope = 1; - // AssignedScope filters tokens by their assigned scope if specified. + // Filter tokens by their assigned scope. teleport.scopes.v1.Filter assigned_scope = 2; - // Cursor is the pagination cursor. + // The pagination cursor. string cursor = 3; - // Limit is the maximum number of results to return. + // The maximum number of results to return. uint32 limit = 4; + + // Filter tokens that apply at least one of the provided roles. + repeated string roles = 5; + + // Filter tokens that match all provided labels. + map labels = 6; } // ListScopedTokensResponse is the response to list scoped tokens. diff --git a/api/proto/teleport/scopes/joining/v1/token.proto b/api/proto/teleport/scopes/joining/v1/token.proto index ed3f43847c14a..6b11a02baee66 100644 --- a/api/proto/teleport/scopes/joining/v1/token.proto +++ b/api/proto/teleport/scopes/joining/v1/token.proto @@ -46,8 +46,15 @@ message ScopedToken { // ScopedTokenSpec is the specification of a scoped token. message ScopedTokenSpec { - // AssignedScope is the scope to which this token is assigned. + // The scope to which this token is assigned. string assigned_scope = 1; - // TODO(fspmarshall): port relevant token features to scoped tokens. + // The list of roles associated with the token. They will be converted + // to metadata in the SSH and X509 certificates issued to the user of the + // token. + repeated string roles = 2; + + // The joining method required in order to use this token. + // Supported joining methods for scoped tokens only include 'token'. + string join_method = 3; } diff --git a/api/types/constants.go b/api/types/constants.go index 2ba317435aea0..64da596b1e91a 100644 --- a/api/types/constants.go +++ b/api/types/constants.go @@ -286,6 +286,9 @@ const ( // KindToken is a provisioning token resource KindToken = "token" + // KindScopedToken is a provisioning token resource + KindScopedToken = "scoped_token" + // KindCertAuthority is a certificate authority resource KindCertAuthority = "cert_authority" diff --git a/lib/auth/auth.go b/lib/auth/auth.go index d3631d3c6f326..f5acab94fb25b 100644 --- a/lib/auth/auth.go +++ b/lib/auth/auth.go @@ -569,6 +569,13 @@ func NewServer(cfg *InitConfig, opts ...ServerOption) (as *Server, err error) { } } + if cfg.ScopedTokenService == nil { + cfg.ScopedTokenService, err = local.NewScopedTokenService(cfg.Backend) + if err != nil { + return nil, trace.Wrap(err) + } + } + scopedAccessCache, err := scopedaccesscache.NewCache(scopedaccesscache.CacheConfig{ Events: cfg.Events, Reader: cfg.ScopedAccess, @@ -635,6 +642,7 @@ func NewServer(cfg *InitConfig, opts ...ServerOption) (as *Server, err error) { RecordingEncryptionManager: cfg.RecordingEncryption, MultipartHandler: cfg.MultipartHandler, Summarizer: cfg.Summarizer, + ScopedTokenService: cfg.ScopedTokenService, } as = &Server{ @@ -903,6 +911,7 @@ type Services struct { RecordingEncryptionManager events.MultipartHandler services.Summarizer + services.ScopedTokenService } // GetWebSession returns existing web session described by req. diff --git a/lib/auth/grpcserver.go b/lib/auth/grpcserver.go index db198ad2beb4c..853ef1bdf6670 100644 --- a/lib/auth/grpcserver.go +++ b/lib/auth/grpcserver.go @@ -5812,6 +5812,8 @@ func NewGRPCServer(cfg GRPCServerConfig) (*GRPCServer, error) { scopedJoining, err := scopedjoining.New(scopedjoining.Config{ Authorizer: cfg.Authorizer, + Backend: cfg.AuthServer, + Logger: logger, }) if err != nil { return nil, trace.Wrap(err, "creating scoped provisioning service") diff --git a/lib/auth/init.go b/lib/auth/init.go index 2478dce133af7..676d0c08b99b9 100644 --- a/lib/auth/init.go +++ b/lib/auth/init.go @@ -413,6 +413,9 @@ type InitConfig struct { // It allows for late initialization of the summarizer in the enterprise // plugin. The summarizer itself summarizes session recordings. SessionSummarizerProvider *summarizer.SessionSummarizerProvider + + // ScopedTokenService is a service that manages scoped join token resources. + ScopedTokenService services.ScopedTokenService } // Init instantiates and configures an instance of AuthServer diff --git a/lib/auth/scopes/joining/service.go b/lib/auth/scopes/joining/service.go index 50298f0edc9fb..8de0e110b6772 100644 --- a/lib/auth/scopes/joining/service.go +++ b/lib/auth/scopes/joining/service.go @@ -14,7 +14,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -package provisioning +package joining import ( "context" @@ -23,15 +23,20 @@ import ( "github.com/gravitational/trace" "github.com/gravitational/teleport" + headerv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/header/v1" scopedjoiningv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/scopes/joining/v1" "github.com/gravitational/teleport/api/types" "github.com/gravitational/teleport/lib/authz" + "github.com/gravitational/teleport/lib/defaults" + "github.com/gravitational/teleport/lib/services" + "github.com/gravitational/teleport/lib/utils" ) // Config contains the parameters for [New]. type Config struct { Authorizer authz.Authorizer Logger *slog.Logger + Backend services.ScopedTokenService } // Server is the [scopedjoiningv1.ScopedJoiningServiceServer] returned by [New]. @@ -40,6 +45,7 @@ type Server struct { authorizer authz.Authorizer logger *slog.Logger + backend services.ScopedTokenService } // New returns the auth server implementation for the scoped provisioning @@ -48,6 +54,11 @@ func New(c Config) (*Server, error) { if c.Authorizer == nil { return nil, trace.BadParameter("missing Authorizer") } + + if c.Backend == nil { + return nil, trace.BadParameter("missing Backend") + } + if c.Logger == nil { c.Logger = slog.With(teleport.ComponentKey, "scopes") } @@ -55,6 +66,7 @@ func New(c Config) (*Server, error) { return &Server{ authorizer: c.Authorizer, logger: c.Logger, + backend: c.Backend, }, nil } @@ -70,7 +82,20 @@ func (s *Server) CreateScopedToken(ctx context.Context, req *scopedjoiningv1.Cre return nil, trace.AccessDenied("user %q does not have permission to create scoped tokens", authzContext.User.GetName()) } - return (scopedjoiningv1.UnimplementedScopedJoiningServiceServer{}).CreateScopedToken(ctx, req) + token := req.GetToken() + if token.GetMetadata().GetName() == "" { + if token.Metadata == nil { + token.Metadata = &headerv1.Metadata{} + } + name, err := utils.CryptoRandomHex(defaults.TokenLenBytes) + if err != nil { + return nil, trace.Wrap(err, "generating token value") + } + token.Metadata.Name = name + } + + res, err := s.backend.CreateScopedToken(ctx, req) + return res, trace.Wrap(err) } // DeleteScopedToken implements [scopedjoiningv1.ScopedJoiningServiceServer]. @@ -85,7 +110,8 @@ func (s *Server) DeleteScopedToken(ctx context.Context, req *scopedjoiningv1.Del return nil, trace.AccessDenied("user %q does not have permission to delete scoped tokens", authzContext.User.GetName()) } - return (scopedjoiningv1.UnimplementedScopedJoiningServiceServer{}).DeleteScopedToken(ctx, req) + res, err := s.backend.DeleteScopedToken(ctx, req) + return res, trace.Wrap(err) } // GetScopedToken implements [scopedjoiningv1.ScopedJoiningServiceServer]. @@ -100,7 +126,8 @@ func (s *Server) GetScopedToken(ctx context.Context, req *scopedjoiningv1.GetSco return nil, trace.AccessDenied("user %q does not have permission to get scoped tokens", authzContext.User.GetName()) } - return (scopedjoiningv1.UnimplementedScopedJoiningServiceServer{}).GetScopedToken(ctx, req) + res, err := s.backend.GetScopedToken(ctx, req) + return res, trace.Wrap(err) } // ListScopedTokens implements [scopedjoiningv1.ScopedJoiningServiceServer]. @@ -115,20 +142,11 @@ func (s *Server) ListScopedTokens(ctx context.Context, req *scopedjoiningv1.List return nil, trace.AccessDenied("user %q does not have permission to list scoped tokens", authzContext.User.GetName()) } - return (scopedjoiningv1.UnimplementedScopedJoiningServiceServer{}).ListScopedTokens(ctx, req) + res, err := s.backend.ListScopedTokens(ctx, req) + return res, trace.Wrap(err) } // UpdateScopedToken implements [scopedjoiningv1.ScopedJoiningServiceServer]. func (s *Server) UpdateScopedToken(ctx context.Context, req *scopedjoiningv1.UpdateScopedTokenRequest) (*scopedjoiningv1.UpdateScopedTokenResponse, error) { - authzContext, err := s.authorizer.Authorize(ctx) - if err != nil { - return nil, trace.Wrap(err) - } - - if !authz.HasBuiltinRole(*authzContext, string(types.RoleAdmin)) { - s.logger.WarnContext(ctx, "user does not have permission to update scoped tokens", "user", authzContext.User.GetName()) - return nil, trace.AccessDenied("user %q does not have permission to update scoped tokens", authzContext.User.GetName()) - } - - return (scopedjoiningv1.UnimplementedScopedJoiningServiceServer{}).UpdateScopedToken(ctx, req) + return nil, trace.NotImplemented("scoped tokens must be recreated, they cannot be updated") } diff --git a/lib/auth/scopes/joining/service_test.go b/lib/auth/scopes/joining/service_test.go new file mode 100644 index 0000000000000..526b18c7774a0 --- /dev/null +++ b/lib/auth/scopes/joining/service_test.go @@ -0,0 +1,201 @@ +// Teleport +// Copyright (C) 2025 Gravitational, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package joining_test + +import ( + "cmp" + "context" + "errors" + "slices" + "testing" + + gocmp "github.com/google/go-cmp/cmp" + "github.com/gravitational/trace" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/testing/protocmp" + + "github.com/gravitational/teleport/api/defaults" + headerv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/header/v1" + joiningv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/scopes/joining/v1" + scopesv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/scopes/v1" + "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/lib/auth/scopes/joining" + "github.com/gravitational/teleport/lib/authz" + "github.com/gravitational/teleport/lib/backend" + "github.com/gravitational/teleport/lib/backend/memory" + "github.com/gravitational/teleport/lib/services" + "github.com/gravitational/teleport/lib/services/local" + "github.com/gravitational/teleport/lib/utils/log/logtest" +) + +func TestScopedJoiningService(t *testing.T) { + ctx := withAuthCtx(t.Context(), newAuthCtx(types.RoleAdmin)) + + bk, err := memory.New(memory.Config{}) + require.NoError(t, err) + svc, err := local.NewScopedTokenService(backend.NewSanitizer(bk)) + require.NoError(t, err) + + service, err := joining.New(joining.Config{ + Logger: logtest.NewLogger(), + Backend: svc, + Authorizer: fakeAuthorizer{}, + }) + require.NoError(t, err) + + token := &joiningv1.ScopedToken{ + Kind: types.KindScopedToken, + Version: types.V1, + Metadata: &headerv1.Metadata{ + Name: "testtoken", + Namespace: defaults.Namespace, + }, + Scope: "/test", + Spec: &joiningv1.ScopedTokenSpec{ + AssignedScope: "/test/aa", + JoinMethod: "token", + Roles: []string{"Node"}, + }, + } + + created, err := service.CreateScopedToken(ctx, &joiningv1.CreateScopedTokenRequest{ + Token: token, + }) + require.NoError(t, err) + cmpOpts := []gocmp.Option{ + protocmp.IgnoreFields(&headerv1.Metadata{}, "revision"), + protocmp.Transform(), + } + assert.Empty(t, gocmp.Diff(token, created.GetToken(), cmpOpts...)) + + tokenWithMismatchedScope := proto.CloneOf(token) + tokenWithMismatchedScope.Metadata.Name = "invalid-token" + tokenWithMismatchedScope.Spec.AssignedScope = "/stage/aa" + + // make sure update is no-op + _, err = service.UpdateScopedToken(ctx, &joiningv1.UpdateScopedTokenRequest{}) + require.True(t, trace.IsNotImplemented(err)) + + _, err = service.CreateScopedToken(ctx, &joiningv1.CreateScopedTokenRequest{ + Token: tokenWithMismatchedScope, + }) + assert.True(t, trace.IsBadParameter(err)) + + tokenWithoutName := proto.CloneOf(token) + tokenWithoutName.Metadata.Name = "" + createdWithoutName, err := service.CreateScopedToken(ctx, &joiningv1.CreateScopedTokenRequest{ + Token: tokenWithoutName, + }) + require.NoError(t, err) + require.NotEmpty(t, createdWithoutName.Token.GetMetadata().GetName()) + + // get token + fetched, err := service.GetScopedToken(ctx, &joiningv1.GetScopedTokenRequest{ + Name: token.Metadata.Name, + }) + require.NoError(t, err) + assert.Empty(t, gocmp.Diff(token, fetched.GetToken(), cmpOpts...)) + + // delete token + _, err = service.DeleteScopedToken(ctx, &joiningv1.DeleteScopedTokenRequest{ + Name: token.Metadata.Name, + }) + require.NoError(t, err) + + // create some tokens + token.Spec.AssignedScope = "/test/bb" + _, err = service.CreateScopedToken(ctx, &joiningv1.CreateScopedTokenRequest{ + Token: token, + }) + require.NoError(t, err) + + token2 := proto.CloneOf(token) + token2.Metadata.Name = "testtoken2" + token2.Scope = "/test/aa" + token2.Spec.AssignedScope = "/test/aa" + _, err = service.CreateScopedToken(ctx, &joiningv1.CreateScopedTokenRequest{ + Token: token2, + }) + require.NoError(t, err) + + token3 := proto.CloneOf(token) + token3.Metadata.Name = "testtoken3" + token3.Scope = "/test/bb" + token3.Spec.AssignedScope = "/test/bb" + _, err = service.CreateScopedToken(ctx, &joiningv1.CreateScopedTokenRequest{ + Token: token3, + }) + require.NoError(t, err) + + res, err := service.ListScopedTokens(ctx, &joiningv1.ListScopedTokensRequest{ + ResourceScope: &scopesv1.Filter{ + Mode: scopesv1.Mode_MODE_POLICIES_APPLICABLE_TO_SCOPE, + Scope: "/test/aa", + }, + }) + require.NoError(t, err) + assert.Len(t, res.Tokens, 3) + sortFn := func(left *joiningv1.ScopedToken, right *joiningv1.ScopedToken) int { + return cmp.Compare(left.Metadata.Name, right.Metadata.Name) + } + + expected := []*joiningv1.ScopedToken{token, tokenWithoutName, token2} + slices.SortStableFunc(res.Tokens, sortFn) + slices.SortStableFunc(expected, sortFn) + for idx, token := range res.Tokens { + assert.Empty(t, gocmp.Diff(expected[idx], token, cmpOpts...)) + } +} + +type fakeChecker struct { + services.AccessChecker + role string +} + +func (f *fakeChecker) HasRole(role string) bool { + return role == f.role +} + +type authKey struct{} + +func withAuthCtx(ctx context.Context, authCtx authz.Context) context.Context { + return context.WithValue(ctx, authKey{}, authCtx) +} + +func newAuthCtx(role types.SystemRole) authz.Context { + return authz.Context{ + Identity: authz.BuiltinRole{ + Role: role, + }, + Checker: &fakeChecker{ + role: string(role), + }, + } +} + +type fakeAuthorizer struct{} + +func (f fakeAuthorizer) Authorize(ctx context.Context) (*authz.Context, error) { + authCtx, ok := ctx.Value(authKey{}).(authz.Context) + if !ok { + return nil, errors.New("no auth context found") + } + + return &authCtx, nil +} diff --git a/lib/scopes/joining/token.go b/lib/scopes/joining/token.go new file mode 100644 index 0000000000000..8b1880b7ee62d --- /dev/null +++ b/lib/scopes/joining/token.go @@ -0,0 +1,125 @@ +// Teleport +// Copyright (C) 2025 Gravitational, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package joining + +import ( + "github.com/gravitational/trace" + + joiningv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/scopes/joining/v1" + "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/lib/scopes" +) + +var rolesSupportingScopes = types.SystemRoles{ + types.RoleNode, +} + +var joinMethodsSupportingScopes = map[string]struct{}{ + string(types.JoinMethodToken): {}, +} + +// StrongValidateToken checks if the scoped token is well-formed according to +// all scoped token rules. This function *must* be used to validate any scoped +// token being created from scratch. When validating existing scoped token +// resources, this function should be avoided in favor of the +// [WeakValidateToken] function. +func StrongValidateToken(token *joiningv1.ScopedToken) error { + if expected, actual := types.KindScopedToken, token.GetKind(); expected != actual { + return trace.BadParameter("expected kind %v, got %q", expected, actual) + } + if expected, actual := types.V1, token.GetVersion(); expected != actual { + return trace.BadParameter("expected version %v, got %q", expected, actual) + } + if expected, actual := "", token.GetSubKind(); expected != actual { + return trace.BadParameter("expected sub_kind %v, got %q", expected, actual) + } + if name := token.GetMetadata().GetName(); name == "" { + return trace.BadParameter("missing name") + } + + if token.GetScope() == "" { + return trace.BadParameter("scoped token must have a scope assigned") + } + + spec := token.GetSpec() + if spec == nil { + return trace.BadParameter("spec must not be nil") + } + + if err := scopes.StrongValidate(token.GetScope()); err != nil { + return trace.Wrap(err, "validating scoped token resource scope") + } + + if err := scopes.StrongValidate(spec.AssignedScope); err != nil { + return trace.Wrap(err, "validating scoped token assigned scope") + } + + if !scopes.ResourceScope(spec.AssignedScope).IsSubjectToPolicyScope(token.GetScope()) { + return trace.BadParameter("scoped token assigned scope must be descendant of its resource scope") + } + + if _, ok := joinMethodsSupportingScopes[spec.JoinMethod]; !ok { + return trace.BadParameter("join method %q does not support scoping", spec.JoinMethod) + } + + if len(spec.Roles) == 0 { + return trace.BadParameter("scoped token must have at least one role") + } + + roles, err := types.NewTeleportRoles(spec.Roles) + if err != nil { + return trace.Wrap(err, "validating scoped token roles") + } + + for _, role := range roles { + if !rolesSupportingScopes.Include(role) { + return trace.BadParameter("role %q does not support scoping", role) + } + } + + return nil +} + +// WeakValidateToken performs a weak form of validation on a scoped token. This +// function is intended to catch bugs/incompatibilites that might have resulted +// in a scoped token too malformed for us to safely reason about (e.g. due to +// significant version drift). Use this function to validate scoped tokens +// propagated from the control plane. Prefer using [StrongValidateToken] when +// building a new scoped token from scratch. +func WeakValidateToken(token *joiningv1.ScopedToken) error { + if token == nil { + return trace.BadParameter("missing scoped token") + } + + if err := scopes.WeakValidate(token.GetScope()); err != nil { + return trace.Wrap(err, "validating scoped token resource scope") + } + + if err := scopes.WeakValidate(token.GetSpec().GetAssignedScope()); err != nil { + return trace.Wrap(err, "validating scoped token assigned scope") + } + + if len(token.GetSpec().GetRoles()) == 0 { + return trace.BadParameter("scoped token must have at least one role") + } + + if _, ok := joinMethodsSupportingScopes[token.GetSpec().GetJoinMethod()]; !ok { + return trace.BadParameter("join method %q does not support scoping", token.GetSpec().GetJoinMethod()) + } + + return nil +} diff --git a/lib/scopes/joining/token_test.go b/lib/scopes/joining/token_test.go new file mode 100644 index 0000000000000..4d35582db853a --- /dev/null +++ b/lib/scopes/joining/token_test.go @@ -0,0 +1,340 @@ +// Teleport +// Copyright (C) 2025 Gravitational, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package joining_test + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + + headerv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/header/v1" + joiningv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/scopes/joining/v1" + "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/lib/scopes/joining" +) + +func TestValidateScopedToken(t *testing.T) { + cases := []struct { + name string + token *joiningv1.ScopedToken + expectedStrongErr string + expectedWeakErr string + }{ + { + name: "invalid kind", + token: &joiningv1.ScopedToken{ + Version: types.V1, + Scope: "/aa", + Metadata: &headerv1.Metadata{ + Name: "testtoken", + }, + Spec: &joiningv1.ScopedTokenSpec{ + AssignedScope: "/aa/bb", + Roles: []string{types.RoleNode.String()}, + JoinMethod: string(types.JoinMethodToken), + }, + }, + expectedStrongErr: fmt.Sprintf("expected kind %v, got %q", types.KindScopedToken, ""), + }, + { + name: "invalid version", + token: &joiningv1.ScopedToken{ + Kind: types.KindScopedToken, + Scope: "/aa", + Metadata: &headerv1.Metadata{ + Name: "testtoken", + }, + Spec: &joiningv1.ScopedTokenSpec{ + AssignedScope: "/aa/bb", + Roles: []string{types.RoleNode.String()}, + JoinMethod: string(types.JoinMethodToken), + }, + }, + expectedStrongErr: fmt.Sprintf("expected version %v, got %q", types.V1, ""), + }, + { + name: "invalid subkind", + token: &joiningv1.ScopedToken{ + Kind: types.KindScopedToken, + Version: types.V1, + Scope: "/aa", + SubKind: "subkind", + Metadata: &headerv1.Metadata{ + Name: "testtoken", + }, + Spec: &joiningv1.ScopedTokenSpec{ + AssignedScope: "/aa/bb", + Roles: []string{types.RoleNode.String()}, + JoinMethod: string(types.JoinMethodToken), + }, + }, + expectedStrongErr: fmt.Sprintf("expected sub_kind %v, got %q", "", "subkind"), + }, + { + name: "missing name", + token: &joiningv1.ScopedToken{ + Kind: types.KindScopedToken, + Version: types.V1, + Scope: "/aa", + Spec: &joiningv1.ScopedTokenSpec{ + AssignedScope: "/aa/bb", + Roles: []string{types.RoleNode.String()}, + JoinMethod: string(types.JoinMethodToken), + }, + }, + expectedStrongErr: "missing name", + }, + { + name: "missing spec", + token: &joiningv1.ScopedToken{ + Kind: types.KindScopedToken, + Version: types.V1, + Scope: "/aa", + Metadata: &headerv1.Metadata{ + Name: "testtoken", + }, + }, + expectedStrongErr: "spec must not be nil", + expectedWeakErr: "validating scoped token assigned scope", + }, + { + name: "missing scope", + token: &joiningv1.ScopedToken{ + Kind: types.KindScopedToken, + Version: types.V1, + Metadata: &headerv1.Metadata{ + Name: "testtoken", + }, + Spec: &joiningv1.ScopedTokenSpec{ + AssignedScope: "/aa/bb", + Roles: []string{types.RoleNode.String()}, + JoinMethod: string(types.JoinMethodToken), + }, + }, + expectedStrongErr: "scoped token must have a scope assigned", + expectedWeakErr: "validating scoped token resource scope", + }, + { + name: "non-absolute scope", + token: &joiningv1.ScopedToken{ + Kind: types.KindScopedToken, + Scope: "aa/bb", + Version: types.V1, + Metadata: &headerv1.Metadata{ + Name: "testtoken", + }, + Spec: &joiningv1.ScopedTokenSpec{ + AssignedScope: "/aa/bb", + Roles: []string{types.RoleNode.String()}, + JoinMethod: string(types.JoinMethodToken), + }, + }, + expectedStrongErr: "validating scoped token resource scope", + }, + { + name: "scope with invalid characters", + token: &joiningv1.ScopedToken{ + Kind: types.KindScopedToken, + Scope: "/aa/bb}", + Version: types.V1, + Metadata: &headerv1.Metadata{ + Name: "testtoken", + }, + Spec: &joiningv1.ScopedTokenSpec{ + AssignedScope: "/aa/bb", + Roles: []string{types.RoleNode.String()}, + JoinMethod: string(types.JoinMethodToken), + }, + }, + expectedStrongErr: "validating scoped token resource scope", + expectedWeakErr: "validating scoped token resource scope", + }, + { + name: "missing assigned scope", + token: &joiningv1.ScopedToken{ + Kind: types.KindScopedToken, + Scope: "/aa/bb", + Version: types.V1, + Metadata: &headerv1.Metadata{ + Name: "testtoken", + }, + Spec: &joiningv1.ScopedTokenSpec{ + Roles: []string{types.RoleNode.String()}, + JoinMethod: string(types.JoinMethodToken), + }, + }, + expectedStrongErr: "validating scoped token assigned scope", + expectedWeakErr: "validating scoped token assigned scope", + }, + { + name: "non-absolute assigned scope", + token: &joiningv1.ScopedToken{ + Kind: types.KindScopedToken, + Scope: "/aa/bb", + Version: types.V1, + Metadata: &headerv1.Metadata{ + Name: "testtoken", + }, + Spec: &joiningv1.ScopedTokenSpec{ + Roles: []string{types.RoleNode.String()}, + AssignedScope: "aa/bb", + JoinMethod: string(types.JoinMethodToken), + }, + }, + expectedStrongErr: "validating scoped token assigned scope", + }, + { + name: "assigned scope with invalid character", + token: &joiningv1.ScopedToken{ + Kind: types.KindScopedToken, + Scope: "/aa/bb", + Version: types.V1, + Metadata: &headerv1.Metadata{ + Name: "testtoken", + }, + Spec: &joiningv1.ScopedTokenSpec{ + Roles: []string{types.RoleNode.String()}, + AssignedScope: "aa/bb}", + JoinMethod: string(types.JoinMethodToken), + }, + }, + expectedStrongErr: "validating scoped token assigned scope", + expectedWeakErr: "validating scoped token assigned scope", + }, + { + name: "assigned scope is not descendant of token scope", + token: &joiningv1.ScopedToken{ + Kind: types.KindScopedToken, + Scope: "/aa/bb", + Version: types.V1, + Metadata: &headerv1.Metadata{ + Name: "testtoken", + }, + Spec: &joiningv1.ScopedTokenSpec{ + Roles: []string{types.RoleNode.String()}, + AssignedScope: "/bb/aa", + JoinMethod: string(types.JoinMethodToken), + }, + }, + expectedStrongErr: "scoped token assigned scope must be descendant of its resource scope", + }, + { + name: "invalid join method", + token: &joiningv1.ScopedToken{ + Kind: types.KindScopedToken, + Scope: "/aa/bb", + Version: types.V1, + Metadata: &headerv1.Metadata{ + Name: "testtoken", + }, + Spec: &joiningv1.ScopedTokenSpec{ + Roles: []string{types.RoleNode.String()}, + AssignedScope: "/aa/bb", + JoinMethod: string(types.JoinMethodUnspecified), + }, + }, + expectedStrongErr: fmt.Sprintf("join method %q does not support scoping", types.JoinMethodUnspecified), + expectedWeakErr: fmt.Sprintf("join method %q does not support scoping", types.JoinMethodUnspecified), + }, + { + name: "missing roles", + token: &joiningv1.ScopedToken{ + Kind: types.KindScopedToken, + Scope: "/aa/bb", + Version: types.V1, + Metadata: &headerv1.Metadata{ + Name: "testtoken", + }, + Spec: &joiningv1.ScopedTokenSpec{ + AssignedScope: "/aa/bb", + JoinMethod: string(types.JoinMethodToken), + }, + }, + expectedStrongErr: "scoped token must have at least one role", + expectedWeakErr: "scoped token must have at least one role", + }, + { + name: "invalid roles", + token: &joiningv1.ScopedToken{ + Kind: types.KindScopedToken, + Scope: "/aa/bb", + Version: types.V1, + Metadata: &headerv1.Metadata{ + Name: "testtoken", + }, + Spec: &joiningv1.ScopedTokenSpec{ + AssignedScope: "/aa/bb", + Roles: []string{"random_role"}, + JoinMethod: string(types.JoinMethodToken), + }, + }, + expectedStrongErr: "validating scoped token roles", + }, + { + name: "unsupported roles", + token: &joiningv1.ScopedToken{ + Kind: types.KindScopedToken, + Scope: "/aa/bb", + Version: types.V1, + Metadata: &headerv1.Metadata{ + Name: "testtoken", + }, + Spec: &joiningv1.ScopedTokenSpec{ + AssignedScope: "/aa/bb", + Roles: []string{types.RoleNode.String(), types.RoleInstance.String()}, + JoinMethod: string(types.JoinMethodToken), + }, + }, + expectedStrongErr: fmt.Sprintf("role %q does not support scoping", types.RoleInstance), + }, + { + name: "valid scoped token", + token: &joiningv1.ScopedToken{ + Kind: types.KindScopedToken, + Scope: "/aa/bb", + Version: types.V1, + Metadata: &headerv1.Metadata{ + Name: "testtoken", + }, + Spec: &joiningv1.ScopedTokenSpec{ + Roles: []string{types.RoleNode.String()}, + AssignedScope: "/aa/bb", + JoinMethod: string(types.JoinMethodToken), + }, + }, + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + err := joining.StrongValidateToken(c.token) + if c.expectedStrongErr == "" { + assert.NoError(t, err) + } else { + assert.ErrorContains(t, err, c.expectedStrongErr) + } + + err = joining.WeakValidateToken(c.token) + if c.expectedWeakErr == "" { + assert.NoError(t, err) + } else { + assert.ErrorContains(t, err, c.expectedWeakErr) + } + }) + } +} diff --git a/lib/services/local/events.go b/lib/services/local/events.go index 50fba5ed89fd4..97403f9c0d2a2 100644 --- a/lib/services/local/events.go +++ b/lib/services/local/events.go @@ -272,6 +272,8 @@ func (e *EventsService) NewWatcher(ctx context.Context, watch types.Watch) (type parser = newRotatedKeyParser() case types.KindRelayServer: parser = newRelayServerParser() + case types.KindScopedToken: + parser = newScopedTokenParser() default: if watch.AllowPartialSuccess { continue diff --git a/lib/services/local/scoped_tokens.go b/lib/services/local/scoped_tokens.go new file mode 100644 index 0000000000000..7cf0f12e16925 --- /dev/null +++ b/lib/services/local/scoped_tokens.go @@ -0,0 +1,230 @@ +// Teleport +// Copyright (C) 2025 Gravitational, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package local + +import ( + "context" + + "github.com/gravitational/trace" + + joiningv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/scopes/joining/v1" + scopesv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/scopes/v1" + "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/lib/backend" + "github.com/gravitational/teleport/lib/scopes" + "github.com/gravitational/teleport/lib/scopes/joining" + "github.com/gravitational/teleport/lib/services" + "github.com/gravitational/teleport/lib/services/local/generic" +) + +const ( + scopedTokenPrefix = "scoped_token" +) + +// ScopedTokenService exposes backend functionality for working with scoped token resources. +type ScopedTokenService struct { + svc *generic.ServiceWrapper[*joiningv1.ScopedToken] +} + +// NewScopedTokenService creates a new ScopedTokenService. +func NewScopedTokenService(b backend.Backend) (*ScopedTokenService, error) { + const pageLimit = 100 + svc, err := generic.NewServiceWrapper(generic.ServiceConfig[*joiningv1.ScopedToken]{ + Backend: b, + PageLimit: pageLimit, + ResourceKind: types.KindScopedToken, + BackendPrefix: backend.NewKey(scopedTokenPrefix), + MarshalFunc: services.MarshalProtoResource[*joiningv1.ScopedToken], + UnmarshalFunc: services.UnmarshalProtoResource[*joiningv1.ScopedToken], + }) + if err != nil { + return nil, trace.Wrap(err) + } + + return &ScopedTokenService{ + svc: svc, + }, nil +} + +// CreateScopedToken adds a scoped token to the auth server. +func (s *ScopedTokenService) CreateScopedToken(ctx context.Context, req *joiningv1.CreateScopedTokenRequest) (*joiningv1.CreateScopedTokenResponse, error) { + if err := joining.StrongValidateToken(req.GetToken()); err != nil { + return nil, trace.Wrap(err) + } + + created, err := s.svc.CreateResource(ctx, req.GetToken()) + return &joiningv1.CreateScopedTokenResponse{ + Token: created, + }, trace.Wrap(err) +} + +// GetScopedToken finds and returns a scoped token by name. +func (s *ScopedTokenService) GetScopedToken(ctx context.Context, req *joiningv1.GetScopedTokenRequest) (*joiningv1.GetScopedTokenResponse, error) { + token, err := s.svc.GetResource(ctx, req.GetName()) + if err != nil { + return nil, trace.Wrap(err) + } + if err := joining.WeakValidateToken(token); err != nil { + return nil, trace.Wrap(err) + } + return &joiningv1.GetScopedTokenResponse{Token: token}, nil +} + +func evalScopeFilter(filter *scopesv1.Filter, scope string) bool { + if filter == nil { + return true + } + + switch filter.Mode { + case scopesv1.Mode_MODE_RESOURCES_SUBJECT_TO_SCOPE: + return scopes.ResourceScope(scope).IsSubjectToPolicyScope(filter.Scope) + case scopesv1.Mode_MODE_POLICIES_APPLICABLE_TO_SCOPE: + return scopes.PolicyScope(scope).AppliesToResourceScope(filter.Scope) + } + + return true +} + +// ListScopedTokens retrieves a paginated list of scoped join tokens. +func (s *ScopedTokenService) ListScopedTokens(ctx context.Context, req *joiningv1.ListScopedTokensRequest) (*joiningv1.ListScopedTokensResponse, error) { + // we only want to return filters if at least one of the filters + // has been defined, otherwise we should return nil so that the + // backend can choose to perform a simple list operation instead + // of a list with filter + switch { + case req.ResourceScope != nil: + case req.AssignedScope != nil: + case len(req.Roles) > 0: + case len(req.Labels) > 0: + default: + tokens, cursor, err := s.svc.ListResources(ctx, int(req.GetLimit()), req.GetCursor()) + + if err != nil { + return nil, trace.Wrap(err) + } + + return &joiningv1.ListScopedTokensResponse{ + Tokens: tokens, + Cursor: cursor, + }, nil + } + + if req.GetAssignedScope().GetScope() != "" { + if err := scopes.WeakValidate(req.GetAssignedScope().GetScope()); err != nil { + return nil, trace.BadParameter("invalid scope for assigned filter: %s", req.GetAssignedScope().GetScope()) + } + + } + + if req.GetResourceScope().GetScope() != "" { + if err := scopes.WeakValidate(req.GetResourceScope().GetScope()); err != nil { + return nil, trace.BadParameter("invalid scope for resource filter: %s", req.GetResourceScope().GetScope()) + } + } + + filterRoles, err := types.NewTeleportRoles(req.GetRoles()) + if err != nil { + return nil, trace.Wrap(err) + } + + filterFn := func(token *joiningv1.ScopedToken) bool { + if len(req.GetRoles()) > 0 { + roles, err := types.NewTeleportRoles(token.Spec.Roles) + if err != nil { + return false + } + + if !filterRoles.IncludeAny(roles...) { + return false + } + } + + if !evalScopeFilter(req.GetAssignedScope(), token.Spec.AssignedScope) { + return false + } + + if !evalScopeFilter(req.GetResourceScope(), token.Scope) { + return false + } + + for k, v := range req.GetLabels() { + if token.GetMetadata().GetLabels()[k] != v { + return false + } + } + + if err := joining.WeakValidateToken(token); err != nil { + return false + } + + return true + } + + tokens, cursor, err := s.svc.ListResourcesWithFilter(ctx, int(req.GetLimit()), req.GetCursor(), filterFn) + if err != nil { + return nil, trace.Wrap(err) + } + + return &joiningv1.ListScopedTokensResponse{ + Tokens: tokens, + Cursor: cursor, + }, nil +} + +// DeleteScopedToken deletes a scoped token by name. +func (s *ScopedTokenService) DeleteScopedToken(ctx context.Context, req *joiningv1.DeleteScopedTokenRequest) (*joiningv1.DeleteScopedTokenResponse, error) { + return nil, trace.Wrap(s.svc.DeleteResource(ctx, req.GetName())) +} + +type scopedTokenParser struct { + baseParser +} + +func newScopedTokenParser() *scopedTokenParser { + return &scopedTokenParser{ + baseParser: newBaseParser(backend.NewKey(scopedTokenPrefix)), + } +} + +func (p *scopedTokenParser) parse(event backend.Event) (types.Resource, error) { + switch event.Type { + case types.OpPut: + scopedToken, err := services.UnmarshalProtoResource[*joiningv1.ScopedToken]( + event.Item.Value, + services.WithExpires(event.Item.Expires), + services.WithRevision(event.Item.Revision), + ) + if err != nil { + return nil, trace.Wrap(err, "unmarshaling resource from event") + } + return types.Resource153ToLegacy(scopedToken), nil + case types.OpDelete: + name := event.Item.Key.TrimPrefix(backend.NewKey(scopedTokenPrefix)).String() + if name == "" { + return nil, trace.NotFound("failed parsing %v", event.Item.Key) + } + return &types.ResourceHeader{ + Kind: types.KindScopedToken, + Version: types.V1, + Metadata: types.Metadata{ + Name: name, + }, + }, nil + default: + return nil, trace.BadParameter("event %v is not supported", event.Type) + } +} diff --git a/lib/services/local/scoped_tokens_test.go b/lib/services/local/scoped_tokens_test.go new file mode 100644 index 0000000000000..b8bb5d4b338f7 --- /dev/null +++ b/lib/services/local/scoped_tokens_test.go @@ -0,0 +1,296 @@ +// Teleport +// Copyright (C) 2025 Gravitational, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package local_test + +import ( + "cmp" + "slices" + "testing" + + gocmp "github.com/google/go-cmp/cmp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/testing/protocmp" + + "github.com/gravitational/teleport/api/defaults" + headerv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/header/v1" + joiningv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/scopes/joining/v1" + scopesv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/scopes/v1" + "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/lib/backend" + "github.com/gravitational/teleport/lib/backend/memory" + "github.com/gravitational/teleport/lib/services/local" +) + +func TestScopedTokenService(t *testing.T) { + bk, err := memory.New(memory.Config{}) + require.NoError(t, err) + service, err := local.NewScopedTokenService(backend.NewSanitizer(bk)) + require.NoError(t, err) + + ctx := t.Context() + + token := &joiningv1.ScopedToken{ + Kind: types.KindScopedToken, + Version: types.V1, + Metadata: &headerv1.Metadata{ + Name: "testtoken", + Namespace: defaults.Namespace, + }, + Scope: "/test", + Spec: &joiningv1.ScopedTokenSpec{ + AssignedScope: "/test/one", + JoinMethod: "token", + Roles: []string{types.RoleNode.String()}, + }, + } + + created, err := service.CreateScopedToken(ctx, &joiningv1.CreateScopedTokenRequest{ + Token: token, + }) + require.NoError(t, err) + cmpOpts := []gocmp.Option{ + protocmp.IgnoreFields(&headerv1.Metadata{}, "revision"), + protocmp.Transform(), + } + assert.Empty(t, gocmp.Diff(token, created.Token, cmpOpts...)) + + fetched, err := service.GetScopedToken(ctx, &joiningv1.GetScopedTokenRequest{ + Name: token.Metadata.Name, + }) + require.NoError(t, err) + assert.Empty(t, gocmp.Diff(created.Token, fetched.Token, cmpOpts...)) +} + +func TestScopedTokenList(t *testing.T) { + bk, err := memory.New(memory.Config{}) + require.NoError(t, err) + service, err := local.NewScopedTokenService(backend.NewSanitizer(bk)) + require.NoError(t, err) + + ctx := t.Context() + + test := &joiningv1.ScopedToken{ + Kind: types.KindScopedToken, + Version: types.V1, + Metadata: &headerv1.Metadata{ + Name: "test", + Namespace: defaults.Namespace, + }, + Scope: "/test", + Spec: &joiningv1.ScopedTokenSpec{ + AssignedScope: "/test", + JoinMethod: "token", + Roles: []string{ + types.RoleNode.String(), + }, + }, + } + + test1 := proto.CloneOf(test) + test1.Metadata.Name = "test1" + test1.Scope = "/test/aa" + test1.Spec.AssignedScope = test1.Scope + + test2 := proto.CloneOf(test) + test2.Metadata.Name = "test2" + test2.Scope = "/test/bb" + test2.Spec.AssignedScope = test2.Scope + test2.Metadata.Labels = map[string]string{ + "hello": "world", + } + + test3 := proto.CloneOf(test) + test3.Metadata.Name = "test3" + test3.Scope = "/test/aa/bb" + test3.Spec.AssignedScope = test3.Scope + + test4 := proto.CloneOf(test) + test4.Metadata.Name = "test4" + test4.Spec.AssignedScope = "/test/aa" + test4.Scope = "/test/aa" + test4.Spec.AssignedScope = test4.Scope + + stage := proto.CloneOf(test) + stage.Metadata.Name = "stage" + stage.Scope = "/stage" + stage.Spec.AssignedScope = stage.Scope + + stage1 := proto.CloneOf(stage) + stage1.Metadata.Name = "stage1" + stage1.Spec.AssignedScope = "/stage/aa" + + stage2 := proto.CloneOf(stage) + stage2.Metadata.Name = "stage2" + stage2.Scope = "/stage/aa" + stage2.Spec.AssignedScope = "/stage/aa" + + allTokens := []*joiningv1.ScopedToken{test, test1, test2, test3, test4, stage, stage1, stage2} + for _, token := range allTokens { + _, err = service.CreateScopedToken(ctx, &joiningv1.CreateScopedTokenRequest{Token: token}) + require.NoError(t, err) + } + + sortFn := func(left *joiningv1.ScopedToken, right *joiningv1.ScopedToken) int { + return cmp.Compare(left.Metadata.Name, right.Metadata.Name) + } + cases := []struct { + name string + req *joiningv1.ListScopedTokensRequest + expected []*joiningv1.ScopedToken + }{ + { + name: "all tokens (no filters)", + req: &joiningv1.ListScopedTokensRequest{}, + expected: []*joiningv1.ScopedToken{test, test1, test2, test3, test4, stage, stage1, stage2}, + }, + { + name: "tokens assigning scope descendant of /test", + req: &joiningv1.ListScopedTokensRequest{ + AssignedScope: &scopesv1.Filter{ + Mode: scopesv1.Mode_MODE_RESOURCES_SUBJECT_TO_SCOPE, + Scope: "/test", + }, + }, + expected: []*joiningv1.ScopedToken{test, test1, test2, test3, test4}, + }, + { + name: "tokens assigning scope descendant of /test/aa", + req: &joiningv1.ListScopedTokensRequest{ + AssignedScope: &scopesv1.Filter{ + Mode: scopesv1.Mode_MODE_RESOURCES_SUBJECT_TO_SCOPE, + Scope: "/test/aa", + }, + }, + expected: []*joiningv1.ScopedToken{test1, test3, test4}, + }, + { + name: "tokens assigning scope ancestor to /test/bb", + req: &joiningv1.ListScopedTokensRequest{ + AssignedScope: &scopesv1.Filter{ + Mode: scopesv1.Mode_MODE_POLICIES_APPLICABLE_TO_SCOPE, + Scope: "/test/bb", + }, + }, + expected: []*joiningv1.ScopedToken{test, test2}, + }, + { + name: "tokens descendants of /test", + req: &joiningv1.ListScopedTokensRequest{ + ResourceScope: &scopesv1.Filter{ + Mode: scopesv1.Mode_MODE_RESOURCES_SUBJECT_TO_SCOPE, + Scope: "/test", + }, + }, + expected: []*joiningv1.ScopedToken{test, test1, test2, test3, test4}, + }, + { + name: "tokens descendants of /test/aa", + req: &joiningv1.ListScopedTokensRequest{ + ResourceScope: &scopesv1.Filter{ + Mode: scopesv1.Mode_MODE_RESOURCES_SUBJECT_TO_SCOPE, + Scope: "/test/aa", + }, + }, + expected: []*joiningv1.ScopedToken{test1, test3, test4}, + }, + { + name: "tokens ancestor to /test/bb", + req: &joiningv1.ListScopedTokensRequest{ + ResourceScope: &scopesv1.Filter{ + Mode: scopesv1.Mode_MODE_POLICIES_APPLICABLE_TO_SCOPE, + Scope: "/test/bb", + }, + }, + expected: []*joiningv1.ScopedToken{test, test2}, + }, + { + name: "tokens descendant of /stage assigning /stage/aa", + req: &joiningv1.ListScopedTokensRequest{ + ResourceScope: &scopesv1.Filter{ + Mode: scopesv1.Mode_MODE_RESOURCES_SUBJECT_TO_SCOPE, + Scope: "/stage", + }, + AssignedScope: &scopesv1.Filter{ + Mode: scopesv1.Mode_MODE_RESOURCES_SUBJECT_TO_SCOPE, + Scope: "/stage/aa", + }, + }, + expected: []*joiningv1.ScopedToken{stage1, stage2}, + }, + { + name: "tokens descendant of /stage/aa assigning /stage/aa", + req: &joiningv1.ListScopedTokensRequest{ + ResourceScope: &scopesv1.Filter{ + Mode: scopesv1.Mode_MODE_RESOURCES_SUBJECT_TO_SCOPE, + Scope: "/stage/aa", + }, + AssignedScope: &scopesv1.Filter{ + Mode: scopesv1.Mode_MODE_RESOURCES_SUBJECT_TO_SCOPE, + Scope: "/stage/aa", + }, + }, + expected: []*joiningv1.ScopedToken{stage2}, + }, + { + name: "tokens in /test scope applying auth role", + req: &joiningv1.ListScopedTokensRequest{ + ResourceScope: &scopesv1.Filter{ + Mode: scopesv1.Mode_MODE_RESOURCES_SUBJECT_TO_SCOPE, + Scope: "/test", + }, + Roles: []string{types.RoleAuth.String()}, + }, + expected: []*joiningv1.ScopedToken{}, + }, + { + name: "tokens in /test scope filtered by label", + req: &joiningv1.ListScopedTokensRequest{ + ResourceScope: &scopesv1.Filter{ + Mode: scopesv1.Mode_MODE_RESOURCES_SUBJECT_TO_SCOPE, + Scope: "/test", + }, + Roles: []string{types.RoleNode.String()}, + Labels: map[string]string{ + "hello": "world", + }, + }, + expected: []*joiningv1.ScopedToken{test2}, + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + req := proto.CloneOf(c.req) + req.Limit = 10 + res, err := service.ListScopedTokens(ctx, req) + require.NoError(t, err) + + slices.SortStableFunc(c.expected, sortFn) + slices.SortStableFunc(res.GetTokens(), sortFn) + require.Len(t, res.GetTokens(), len(c.expected)) + for i, token := range res.GetTokens() { + cmpOpts := []gocmp.Option{ + protocmp.IgnoreFields(&headerv1.Metadata{}, "revision"), + protocmp.Transform(), + } + assert.Empty(t, gocmp.Diff(c.expected[i], token, cmpOpts...)) + } + }) + } +} diff --git a/lib/services/scoped_tokens.go b/lib/services/scoped_tokens.go new file mode 100644 index 0000000000000..80b0234d9090a --- /dev/null +++ b/lib/services/scoped_tokens.go @@ -0,0 +1,39 @@ +// Teleport +// Copyright (C) 2025 Gravitational, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package services + +import ( + "context" + + joiningv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/scopes/joining/v1" +) + +// ScopedTokenService handles CRUD operations for the ScopedToken resource. +type ScopedTokenService interface { + // CreateScopedToken creates a scoped join token. + CreateScopedToken(ctx context.Context, req *joiningv1.CreateScopedTokenRequest) (*joiningv1.CreateScopedTokenResponse, error) + + // GetScopedToken fetches a scoped join token by unique name + GetScopedToken(ctx context.Context, req *joiningv1.GetScopedTokenRequest) (*joiningv1.GetScopedTokenResponse, error) + + // ListScopedTokens retrieves a paginated list of scoped join tokens + ListScopedTokens(ctx context.Context, req *joiningv1.ListScopedTokensRequest) (*joiningv1.ListScopedTokensResponse, error) + + // DeleteScopedToken deletes a named scoped join token. Imlementations must guarantee that + // this returns trace.NotFound error if the token doesn't exist + DeleteScopedToken(ctx context.Context, req *joiningv1.DeleteScopedTokenRequest) (*joiningv1.DeleteScopedTokenResponse, error) +}