diff --git a/lib/auth/discoveryconfig/discoveryconfigv1/service.go b/lib/auth/discoveryconfig/discoveryconfigv1/service.go new file mode 100644 index 0000000000000..00092cd0c2152 --- /dev/null +++ b/lib/auth/discoveryconfig/discoveryconfigv1/service.go @@ -0,0 +1,199 @@ +/* +Copyright 2023 Gravitational, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package discoveryconfigv1 + +import ( + "context" + + "github.com/gravitational/trace" + "github.com/jonboulle/clockwork" + "github.com/sirupsen/logrus" + "google.golang.org/protobuf/types/known/emptypb" + + discoveryconfigv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/discoveryconfig/v1" + "github.com/gravitational/teleport/api/types" + conv "github.com/gravitational/teleport/api/types/discoveryconfig/convert/v1" + "github.com/gravitational/teleport/lib/authz" + "github.com/gravitational/teleport/lib/services" +) + +// ServiceConfig holds configuration options for the DiscoveryConfig gRPC service. +type ServiceConfig struct { + // Logger is the logger to use. + Logger logrus.FieldLogger + + // Authorizer is the authorizer to use. + Authorizer authz.Authorizer + + // Backend is the backend for storing DiscoveryConfigs. + Backend services.DiscoveryConfigs + + // Clock is the clock. + Clock clockwork.Clock +} + +// CheckAndSetDefaults checks the ServiceConfig fields and returns an error if +// a required param is not provided. +// Authorizer, Cache and Backend are required params +func (s *ServiceConfig) CheckAndSetDefaults() error { + if s.Authorizer == nil { + return trace.BadParameter("authorizer is required") + } + if s.Backend == nil { + return trace.BadParameter("backend is required") + } + + if s.Logger == nil { + s.Logger = logrus.New().WithField(trace.Component, "discoveryconfig_crud_service") + } + + if s.Clock == nil { + s.Clock = clockwork.NewRealClock() + } + + return nil +} + +// Service implements the teleport.DiscoveryConfig.v1.DiscoveryConfigService RPC service. +type Service struct { + discoveryconfigv1.UnimplementedDiscoveryConfigServiceServer + + log logrus.FieldLogger + authorizer authz.Authorizer + backend services.DiscoveryConfigs + clock clockwork.Clock +} + +// NewService returns a new DiscoveryConfigs gRPC service. +func NewService(cfg ServiceConfig) (*Service, error) { + if err := cfg.CheckAndSetDefaults(); err != nil { + return nil, trace.Wrap(err) + } + + return &Service{ + log: cfg.Logger, + authorizer: cfg.Authorizer, + backend: cfg.Backend, + clock: cfg.Clock, + }, nil +} + +// ListDiscoveryConfigs returns a paginated list of all DiscoveryConfig resources. +func (s *Service) ListDiscoveryConfigs(ctx context.Context, req *discoveryconfigv1.ListDiscoveryConfigsRequest) (*discoveryconfigv1.ListDiscoveryConfigsResponse, error) { + _, err := authz.AuthorizeWithVerbs(ctx, s.log, s.authorizer, true, types.KindDiscoveryConfig, types.VerbRead, types.VerbList) + if err != nil { + return nil, trace.Wrap(err) + } + + results, nextKey, err := s.backend.ListDiscoveryConfigs(ctx, int(req.GetPageSize()), req.GetNextToken()) + if err != nil { + return nil, trace.Wrap(err) + } + + dcs := make([]*discoveryconfigv1.DiscoveryConfig, len(results)) + for i, r := range results { + dcs[i] = conv.ToProto(r) + } + + return &discoveryconfigv1.ListDiscoveryConfigsResponse{ + DiscoveryConfigs: dcs, + NextKey: nextKey, + }, nil +} + +// GetDiscoveryConfig returns the specified DiscoveryConfig resource. +func (s *Service) GetDiscoveryConfig(ctx context.Context, req *discoveryconfigv1.GetDiscoveryConfigRequest) (*discoveryconfigv1.DiscoveryConfig, error) { + _, err := authz.AuthorizeWithVerbs(ctx, s.log, s.authorizer, true, types.KindDiscoveryConfig, types.VerbRead) + if err != nil { + return nil, trace.Wrap(err) + } + + dc, err := s.backend.GetDiscoveryConfig(ctx, req.Name) + if err != nil { + return nil, trace.Wrap(err) + } + + return conv.ToProto(dc), nil +} + +// CreateDiscoveryConfig creates a new DiscoveryConfig resource. +func (s *Service) CreateDiscoveryConfig(ctx context.Context, req *discoveryconfigv1.CreateDiscoveryConfigRequest) (*discoveryconfigv1.DiscoveryConfig, error) { + _, err := authz.AuthorizeWithVerbs(ctx, s.log, s.authorizer, true, types.KindDiscoveryConfig, types.VerbCreate) + if err != nil { + return nil, trace.Wrap(err) + } + + dc, err := conv.FromProto(req.GetDiscoveryConfig()) + if err != nil { + return nil, trace.Wrap(err) + } + + resp, err := s.backend.CreateDiscoveryConfig(ctx, dc) + if err != nil { + return nil, trace.Wrap(err) + } + + return conv.ToProto(resp), nil +} + +// UpdateDiscoveryConfig updates an existing DiscoveryConfig. +func (s *Service) UpdateDiscoveryConfig(ctx context.Context, req *discoveryconfigv1.UpdateDiscoveryConfigRequest) (*discoveryconfigv1.DiscoveryConfig, error) { + _, err := authz.AuthorizeWithVerbs(ctx, s.log, s.authorizer, true, types.KindDiscoveryConfig, types.VerbUpdate) + if err != nil { + return nil, trace.Wrap(err) + } + + dc, err := conv.FromProto(req.GetDiscoveryConfig()) + if err != nil { + return nil, trace.Wrap(err) + } + + resp, err := s.backend.UpdateDiscoveryConfig(ctx, dc) + if err != nil { + return nil, trace.Wrap(err) + } + + return conv.ToProto(resp), nil +} + +// DeleteDiscoveryConfig removes the specified DiscoveryConfig resource. +func (s *Service) DeleteDiscoveryConfig(ctx context.Context, req *discoveryconfigv1.DeleteDiscoveryConfigRequest) (*emptypb.Empty, error) { + _, err := authz.AuthorizeWithVerbs(ctx, s.log, s.authorizer, true, types.KindDiscoveryConfig, types.VerbDelete) + if err != nil { + return nil, trace.Wrap(err) + } + + if err := s.backend.DeleteDiscoveryConfig(ctx, req.GetName()); err != nil { + return nil, trace.Wrap(err) + } + + return &emptypb.Empty{}, nil +} + +// DeleteAllDiscoveryConfigs removes all DiscoveryConfig resources. +func (s *Service) DeleteAllDiscoveryConfigs(ctx context.Context, _ *discoveryconfigv1.DeleteAllDiscoveryConfigsRequest) (*emptypb.Empty, error) { + _, err := authz.AuthorizeWithVerbs(ctx, s.log, s.authorizer, true, types.KindDiscoveryConfig, types.VerbDelete) + if err != nil { + return nil, trace.Wrap(err) + } + + if err := s.backend.DeleteAllDiscoveryConfigs(ctx); err != nil { + return nil, trace.Wrap(err) + } + + return &emptypb.Empty{}, nil +} diff --git a/lib/auth/discoveryconfig/discoveryconfigv1/service_test.go b/lib/auth/discoveryconfig/discoveryconfigv1/service_test.go new file mode 100644 index 0000000000000..eff6a869ae9a9 --- /dev/null +++ b/lib/auth/discoveryconfig/discoveryconfigv1/service_test.go @@ -0,0 +1,398 @@ +/* +Copyright 2023 Gravitational, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package discoveryconfigv1 + +import ( + "context" + "testing" + + "github.com/google/uuid" + "github.com/gravitational/trace" + "github.com/stretchr/testify/require" + + discoveryconfigpb "github.com/gravitational/teleport/api/gen/proto/go/teleport/discoveryconfig/v1" + "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/api/types/discoveryconfig" + convert "github.com/gravitational/teleport/api/types/discoveryconfig/convert/v1" + "github.com/gravitational/teleport/api/types/header" + "github.com/gravitational/teleport/lib/authz" + "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/tlsca" +) + +func TestDiscoveryConfigCRUD(t *testing.T) { + t.Parallel() + clusterName := "test-cluster" + + requireTraceErrorFn := func(traceFn func(error) bool) require.ErrorAssertionFunc { + return func(tt require.TestingT, err error, i ...interface{}) { + require.True(t, traceFn(err), "received an un-expected error: %v", err) + } + } + + ctx, localClient, resourceSvc := initSvc(t, clusterName) + + sampleDiscoveryConfigFn := func(t *testing.T, name string) *discoveryconfig.DiscoveryConfig { + dc, err := discoveryconfig.NewDiscoveryConfig( + header.Metadata{Name: name}, + discoveryconfig.Spec{ + DiscoveryGroup: "some-group", + }, + ) + require.NoError(t, err) + return dc + } + + tt := []struct { + Name string + Role types.RoleSpecV6 + Setup func(t *testing.T, dcName string) + Test func(ctx context.Context, resourceSvc *Service, dcName string) error + ErrAssertion require.ErrorAssertionFunc + }{ + // Read + { + Name: "allowed read access to discovery configs", + Role: types.RoleSpecV6{ + Allow: types.RoleConditions{Rules: []types.Rule{{ + Resources: []string{types.KindDiscoveryConfig}, + Verbs: []string{types.VerbRead}, + }}}, + }, + Setup: func(t *testing.T, dcName string) { + _, err := localClient.CreateDiscoveryConfig(ctx, sampleDiscoveryConfigFn(t, dcName)) + require.NoError(t, err) + }, + Test: func(ctx context.Context, resourceSvc *Service, dcName string) error { + _, err := resourceSvc.GetDiscoveryConfig(ctx, &discoveryconfigpb.GetDiscoveryConfigRequest{ + Name: dcName, + }) + return err + }, + ErrAssertion: require.NoError, + }, + { + Name: "no access to read discovery configs", + Role: types.RoleSpecV6{}, + Test: func(ctx context.Context, resourceSvc *Service, dcName string) error { + _, err := resourceSvc.GetDiscoveryConfig(ctx, &discoveryconfigpb.GetDiscoveryConfigRequest{ + Name: dcName, + }) + return err + }, + ErrAssertion: requireTraceErrorFn(trace.IsAccessDenied), + }, + { + Name: "denied access to read discovery configs", + Role: types.RoleSpecV6{ + Deny: types.RoleConditions{Rules: []types.Rule{{ + Resources: []string{types.KindDiscoveryConfig}, + Verbs: []string{types.VerbRead}, + }}}, + }, + Test: func(ctx context.Context, resourceSvc *Service, dcName string) error { + _, err := resourceSvc.GetDiscoveryConfig(ctx, &discoveryconfigpb.GetDiscoveryConfigRequest{ + Name: dcName, + }) + return err + }, + ErrAssertion: requireTraceErrorFn(trace.IsAccessDenied), + }, + + // List + { + Name: "allowed list access to discovery configs", + Role: types.RoleSpecV6{ + Allow: types.RoleConditions{Rules: []types.Rule{{ + Resources: []string{types.KindDiscoveryConfig}, + Verbs: []string{types.VerbList, types.VerbRead}, + }}}, + }, + Setup: func(t *testing.T, _ string) { + for i := 0; i < 10; i++ { + _, err := localClient.CreateDiscoveryConfig(ctx, sampleDiscoveryConfigFn(t, uuid.NewString())) + require.NoError(t, err) + } + }, + Test: func(ctx context.Context, resourceSvc *Service, dcName string) error { + _, err := resourceSvc.ListDiscoveryConfigs(ctx, &discoveryconfigpb.ListDiscoveryConfigsRequest{ + PageSize: 0, + NextToken: "", + }) + return err + }, + ErrAssertion: require.NoError, + }, + { + Name: "no list access to discovery config", + Role: types.RoleSpecV6{ + Allow: types.RoleConditions{Rules: []types.Rule{{ + Resources: []string{types.KindDiscoveryConfig}, + Verbs: []string{types.VerbCreate}, + }}}, + }, + Test: func(ctx context.Context, resourceSvc *Service, dcName string) error { + _, err := resourceSvc.ListDiscoveryConfigs(ctx, &discoveryconfigpb.ListDiscoveryConfigsRequest{ + PageSize: 0, + NextToken: "", + }) + return err + }, + ErrAssertion: requireTraceErrorFn(trace.IsAccessDenied), + }, + + // Create + { + Name: "no access to create discovery configs", + Role: types.RoleSpecV6{}, + Test: func(ctx context.Context, resourceSvc *Service, dcName string) error { + dc := sampleDiscoveryConfigFn(t, dcName) + _, err := resourceSvc.CreateDiscoveryConfig(ctx, &discoveryconfigpb.CreateDiscoveryConfigRequest{ + DiscoveryConfig: convert.ToProto(dc), + }) + return err + }, + ErrAssertion: requireTraceErrorFn(trace.IsAccessDenied), + }, + { + Name: "access to create discovery configs", + Role: types.RoleSpecV6{ + Allow: types.RoleConditions{Rules: []types.Rule{{ + Resources: []string{types.KindDiscoveryConfig}, + Verbs: []string{types.VerbCreate}, + }}}, + }, + Test: func(ctx context.Context, resourceSvc *Service, dcName string) error { + dc := sampleDiscoveryConfigFn(t, dcName) + _, err := resourceSvc.CreateDiscoveryConfig(ctx, &discoveryconfigpb.CreateDiscoveryConfigRequest{ + DiscoveryConfig: convert.ToProto(dc), + }) + return err + }, + ErrAssertion: require.NoError, + }, + + // Update + { + Name: "no access to update discovery config", + Role: types.RoleSpecV6{}, + Test: func(ctx context.Context, resourceSvc *Service, dcName string) error { + dc := sampleDiscoveryConfigFn(t, dcName) + _, err := resourceSvc.UpdateDiscoveryConfig(ctx, &discoveryconfigpb.UpdateDiscoveryConfigRequest{ + DiscoveryConfig: convert.ToProto(dc), + }) + return err + }, + ErrAssertion: requireTraceErrorFn(trace.IsAccessDenied), + }, + { + Name: "access to update discovery config", + Role: types.RoleSpecV6{ + Allow: types.RoleConditions{Rules: []types.Rule{{ + Resources: []string{types.KindDiscoveryConfig}, + Verbs: []string{types.VerbUpdate}, + }}}, + }, + Setup: func(t *testing.T, dcName string) { + _, err := localClient.CreateDiscoveryConfig(ctx, sampleDiscoveryConfigFn(t, dcName)) + require.NoError(t, err) + }, + Test: func(ctx context.Context, resourceSvc *Service, dcName string) error { + dc := sampleDiscoveryConfigFn(t, dcName) + _, err := resourceSvc.UpdateDiscoveryConfig(ctx, &discoveryconfigpb.UpdateDiscoveryConfigRequest{ + DiscoveryConfig: convert.ToProto(dc), + }) + return err + }, + ErrAssertion: require.NoError, + }, + + // Delete + { + Name: "no access to delete discovery config", + Role: types.RoleSpecV6{}, + Test: func(ctx context.Context, resourceSvc *Service, dcName string) error { + _, err := resourceSvc.DeleteDiscoveryConfig(ctx, &discoveryconfigpb.DeleteDiscoveryConfigRequest{Name: "x"}) + return err + }, + ErrAssertion: requireTraceErrorFn(trace.IsAccessDenied), + }, + { + Name: "access to delete discovery config", + Role: types.RoleSpecV6{ + Allow: types.RoleConditions{Rules: []types.Rule{{ + Resources: []string{types.KindDiscoveryConfig}, + Verbs: []string{types.VerbDelete}, + }}}, + }, + Setup: func(t *testing.T, dcName string) { + _, err := localClient.CreateDiscoveryConfig(ctx, sampleDiscoveryConfigFn(t, dcName)) + require.NoError(t, err) + }, + Test: func(ctx context.Context, resourceSvc *Service, dcName string) error { + _, err := resourceSvc.DeleteDiscoveryConfig(ctx, &discoveryconfigpb.DeleteDiscoveryConfigRequest{Name: dcName}) + return err + }, + ErrAssertion: require.NoError, + }, + + // Delete all + { + Name: "remove all discovery configs fails when no access", + Role: types.RoleSpecV6{}, + Test: func(ctx context.Context, resourceSvc *Service, dcName string) error { + _, err := resourceSvc.DeleteAllDiscoveryConfigs(ctx, &discoveryconfigpb.DeleteAllDiscoveryConfigsRequest{}) + return err + }, + ErrAssertion: requireTraceErrorFn(trace.IsAccessDenied), + }, + { + Name: "remove all discovery configs", + Role: types.RoleSpecV6{ + Allow: types.RoleConditions{Rules: []types.Rule{{ + Resources: []string{types.KindDiscoveryConfig}, + Verbs: []string{types.VerbDelete}, + }}}, + }, + Setup: func(t *testing.T, _ string) { + for i := 0; i < 10; i++ { + _, err := localClient.CreateDiscoveryConfig(ctx, sampleDiscoveryConfigFn(t, uuid.NewString())) + require.NoError(t, err) + } + }, + Test: func(ctx context.Context, resourceSvc *Service, dcName string) error { + _, err := resourceSvc.DeleteAllDiscoveryConfigs(ctx, &discoveryconfigpb.DeleteAllDiscoveryConfigsRequest{}) + return err + }, + ErrAssertion: require.NoError, + }, + } + + for _, tc := range tt { + tc := tc + t.Run(tc.Name, func(t *testing.T) { + localCtx := authorizerForDummyUser(t, ctx, tc.Role, localClient) + + dcName := uuid.NewString() + if tc.Setup != nil { + tc.Setup(t, dcName) + } + + err := tc.Test(localCtx, resourceSvc, dcName) + tc.ErrAssertion(t, err) + }) + } +} + +func authorizerForDummyUser(t *testing.T, ctx context.Context, roleSpec types.RoleSpecV6, localClient localClient) context.Context { + // Create role + roleName := "role-" + uuid.NewString() + role, err := types.NewRole(roleName, roleSpec) + require.NoError(t, err) + + err = localClient.CreateRole(ctx, role) + require.NoError(t, err) + + // Create user + user, err := types.NewUser("user-" + uuid.NewString()) + require.NoError(t, err) + user.AddRole(roleName) + err = localClient.CreateUser(user) + require.NoError(t, err) + + return authz.ContextWithUser(ctx, authz.LocalUser{ + Username: user.GetName(), + Identity: tlsca.Identity{ + Username: user.GetName(), + Groups: []string{role.GetName()}, + }, + }) +} + +type localClient interface { + CreateUser(user types.User) error + CreateRole(ctx context.Context, role types.Role) error + CreateDiscoveryConfig(ctx context.Context, dc *discoveryconfig.DiscoveryConfig) (*discoveryconfig.DiscoveryConfig, error) +} + +func initSvc(t *testing.T, clusterName string) (context.Context, localClient, *Service) { + ctx := context.Background() + backend, err := memory.New(memory.Config{}) + require.NoError(t, err) + + trustSvc := local.NewCAService(backend) + roleSvc := local.NewAccessService(backend) + userSvc := local.NewIdentityService(backend) + + clusterConfigSvc, err := local.NewClusterConfigurationService(backend) + require.NoError(t, err) + require.NoError(t, clusterConfigSvc.SetAuthPreference(ctx, types.DefaultAuthPreference())) + require.NoError(t, clusterConfigSvc.SetClusterAuditConfig(ctx, types.DefaultClusterAuditConfig())) + require.NoError(t, clusterConfigSvc.SetClusterNetworkingConfig(ctx, types.DefaultClusterNetworkingConfig())) + require.NoError(t, clusterConfigSvc.SetSessionRecordingConfig(ctx, types.DefaultSessionRecordingConfig())) + + accessPoint := struct { + services.ClusterConfiguration + services.Trust + services.RoleGetter + services.UserGetter + }{ + ClusterConfiguration: clusterConfigSvc, + Trust: trustSvc, + RoleGetter: roleSvc, + UserGetter: userSvc, + } + + accessService := local.NewAccessService(backend) + eventService := local.NewEventsService(backend) + lockWatcher, err := services.NewLockWatcher(ctx, services.LockWatcherConfig{ + ResourceWatcherConfig: services.ResourceWatcherConfig{ + Client: eventService, + Component: "test", + }, + LockGetter: accessService, + }) + require.NoError(t, err) + + authorizer, err := authz.NewAuthorizer(authz.AuthorizerOpts{ + ClusterName: clusterName, + AccessPoint: accessPoint, + LockWatcher: lockWatcher, + }) + require.NoError(t, err) + + localResourceService, err := local.NewDiscoveryConfigService(backend) + require.NoError(t, err) + + resourceSvc, err := NewService(ServiceConfig{ + Backend: localResourceService, + Authorizer: authorizer, + }) + require.NoError(t, err) + + return ctx, struct { + *local.AccessService + *local.IdentityService + *local.DiscoveryConfigService + }{ + AccessService: roleSvc, + IdentityService: userSvc, + DiscoveryConfigService: localResourceService, + }, resourceSvc +}