From dae0e0d478acf0eceee7260518edb2d0428f363e Mon Sep 17 00:00:00 2001 From: Marco Dinis Date: Fri, 20 Dec 2024 09:25:58 +0000 Subject: [PATCH 01/46] AWS OIDC: List Deployed Database Services HTTP API (#49352) * AWS OIDC: List Deployed Database Services HTTP API This PR adds a new endpoint which returns the deployed database services. Calling the ECS APIs requires a region, so we had to iterate over the following resources to collect the relevant regions: - databases - database services - discovery configs * extract loops * improve config parse and moved endpoint to a GET * revert http verb for listing * remove pointer --- .../deployservice_config.go | 23 ++ .../deployservice_config_test.go | 44 +++ lib/web/apiserver.go | 1 + lib/web/integrations_awsoidc.go | 227 ++++++++++++ lib/web/integrations_awsoidc_test.go | 334 ++++++++++++++++++ lib/web/ui/integration.go | 20 ++ 6 files changed, 649 insertions(+) diff --git a/lib/integrations/awsoidc/deployserviceconfig/deployservice_config.go b/lib/integrations/awsoidc/deployserviceconfig/deployservice_config.go index 1f2624b94d6c7..941ba7681f7c0 100644 --- a/lib/integrations/awsoidc/deployserviceconfig/deployservice_config.go +++ b/lib/integrations/awsoidc/deployserviceconfig/deployservice_config.go @@ -89,3 +89,26 @@ func GenerateTeleportConfigString(proxyHostPort, iamTokenName string, resourceMa return teleportConfigString, nil } + +// ParseResourceLabelMatchers receives a teleport config string and returns the Resource Matcher Label. +// The expected input is a base64 encoded yaml string containing a teleport configuration, +// the same format that GenerateTeleportConfigString returns. +func ParseResourceLabelMatchers(teleportConfigStringBase64 string) (types.Labels, error) { + teleportConfigString, err := base64.StdEncoding.DecodeString(teleportConfigStringBase64) + if err != nil { + return nil, trace.BadParameter("invalid base64 value, error=%v", err) + } + + var teleportConfig config.FileConfig + if err := yaml.Unmarshal(teleportConfigString, &teleportConfig); err != nil { + return nil, trace.BadParameter("invalid teleport config, error=%v", err) + } + + if len(teleportConfig.Databases.ResourceMatchers) == 0 { + return nil, trace.BadParameter("valid yaml configuration but db_service.resources has 0 items") + } + + resourceMatchers := teleportConfig.Databases.ResourceMatchers[0] + + return resourceMatchers.Labels, nil +} diff --git a/lib/integrations/awsoidc/deployserviceconfig/deployservice_config_test.go b/lib/integrations/awsoidc/deployserviceconfig/deployservice_config_test.go index 1f47d96e2dac4..3b40912ac9160 100644 --- a/lib/integrations/awsoidc/deployserviceconfig/deployservice_config_test.go +++ b/lib/integrations/awsoidc/deployserviceconfig/deployservice_config_test.go @@ -23,8 +23,10 @@ import ( "testing" "github.com/stretchr/testify/require" + "gopkg.in/yaml.v2" "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/api/utils" ) func TestDeployServiceConfig(t *testing.T) { @@ -39,3 +41,45 @@ func TestDeployServiceConfig(t *testing.T) { require.Contains(t, base64Config, base64SeverityDebug) }) } + +func TestParseResourceLabelMatchers(t *testing.T) { + labels := types.Labels{ + "vpc": utils.Strings{"vpc-1", "vpc-2"}, + "region": utils.Strings{"us-west-2"}, + "xyz": utils.Strings{}, + } + base64Config, err := GenerateTeleportConfigString("host:port", "iam-token", labels) + require.NoError(t, err) + + t.Run("recover matching labels", func(t *testing.T) { + gotLabels, err := ParseResourceLabelMatchers(base64Config) + require.NoError(t, err) + + require.Equal(t, labels, gotLabels) + }) + + t.Run("fails if invalid base64 string", func(t *testing.T) { + _, err := ParseResourceLabelMatchers("invalid base 64") + require.ErrorContains(t, err, "base64") + }) + + t.Run("invalid yaml", func(t *testing.T) { + input := base64.StdEncoding.EncodeToString([]byte("invalid yaml")) + _, err := ParseResourceLabelMatchers(input) + require.ErrorContains(t, err, "yaml") + }) + + t.Run("valid yaml but not a teleport config", func(t *testing.T) { + yamlInput := struct { + DBService string `yaml:"db_service"` + }{ + DBService: "not a valid teleport config", + } + yamlBS, err := yaml.Marshal(yamlInput) + require.NoError(t, err) + input := base64.StdEncoding.EncodeToString(yamlBS) + + _, err = ParseResourceLabelMatchers(input) + require.ErrorContains(t, err, "invalid teleport config") + }) +} diff --git a/lib/web/apiserver.go b/lib/web/apiserver.go index 451f5668a14a2..9a7a0ac625be8 100644 --- a/lib/web/apiserver.go +++ b/lib/web/apiserver.go @@ -996,6 +996,7 @@ func (h *Handler) bindDefaultEndpoints() { h.GET("/webapi/scripts/integrations/configure/listdatabases-iam.sh", h.WithLimiter(h.awsOIDCConfigureListDatabasesIAM)) h.POST("/webapi/sites/:site/integrations/aws-oidc/:name/deployservice", h.WithClusterAuth(h.awsOIDCDeployService)) h.POST("/webapi/sites/:site/integrations/aws-oidc/:name/deploydatabaseservices", h.WithClusterAuth(h.awsOIDCDeployDatabaseServices)) + h.POST("/webapi/sites/:site/integrations/aws-oidc/:name/listdeployeddatabaseservices", h.WithClusterAuth(h.awsOIDCListDeployedDatabaseService)) h.GET("/webapi/scripts/integrations/configure/deployservice-iam.sh", h.WithLimiter(h.awsOIDCConfigureDeployServiceIAM)) h.POST("/webapi/sites/:site/integrations/aws-oidc/:name/ec2", h.WithClusterAuth(h.awsOIDCListEC2)) h.POST("/webapi/sites/:site/integrations/aws-oidc/:name/eksclusters", h.WithClusterAuth(h.awsOIDCListEKSClusters)) diff --git a/lib/web/integrations_awsoidc.go b/lib/web/integrations_awsoidc.go index 44444f191c149..844391b1523f9 100644 --- a/lib/web/integrations_awsoidc.go +++ b/lib/web/integrations_awsoidc.go @@ -23,6 +23,8 @@ import ( "encoding/base64" "encoding/json" "fmt" + "log/slog" + "maps" "net/http" "net/url" "slices" @@ -31,6 +33,7 @@ import ( "github.com/google/safetext/shsprintf" "github.com/gravitational/trace" "github.com/julienschmidt/httprouter" + "google.golang.org/grpc" "github.com/gravitational/teleport" "github.com/gravitational/teleport/api/client" @@ -39,6 +42,7 @@ import ( apidefaults "github.com/gravitational/teleport/api/defaults" integrationv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/integration/v1" "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/api/types/discoveryconfig" "github.com/gravitational/teleport/api/utils" "github.com/gravitational/teleport/api/utils/aws" "github.com/gravitational/teleport/lib/auth/authclient" @@ -49,6 +53,7 @@ import ( kubeutils "github.com/gravitational/teleport/lib/kube/utils" "github.com/gravitational/teleport/lib/reversetunnelclient" "github.com/gravitational/teleport/lib/services" + libui "github.com/gravitational/teleport/lib/ui" libutils "github.com/gravitational/teleport/lib/utils" awsutils "github.com/gravitational/teleport/lib/utils/aws" "github.com/gravitational/teleport/lib/utils/oidc" @@ -260,6 +265,228 @@ func (h *Handler) awsOIDCDeployDatabaseServices(w http.ResponseWriter, r *http.R }, nil } +// awsOIDCListDeployedDatabaseService lists the deployed Database Services in Amazon ECS. +func (h *Handler) awsOIDCListDeployedDatabaseService(w http.ResponseWriter, r *http.Request, p httprouter.Params, sctx *SessionContext, site reversetunnelclient.RemoteSite) (any, error) { + ctx := r.Context() + clt, err := sctx.GetUserClient(ctx, site) + if err != nil { + return nil, trace.Wrap(err) + } + + integrationName := p.ByName("name") + if integrationName == "" { + return nil, trace.BadParameter("an integration name is required") + } + + regions, err := fetchRelevantAWSRegions(ctx, clt, clt.DiscoveryConfigClient()) + if err != nil { + return nil, trace.Wrap(err) + } + + services, err := listDeployedDatabaseServices(ctx, h.logger, integrationName, regions, clt.IntegrationAWSOIDCClient()) + if err != nil { + return nil, trace.Wrap(err) + } + + return ui.AWSOIDCListDeployedDatabaseServiceResponse{ + Services: services, + }, nil +} + +type databaseGetter interface { + GetResources(ctx context.Context, req *proto.ListResourcesRequest) (*proto.ListResourcesResponse, error) + GetDatabases(context.Context) ([]types.Database, error) +} + +type discoveryConfigLister interface { + ListDiscoveryConfigs(ctx context.Context, pageSize int, nextToken string) ([]*discoveryconfig.DiscoveryConfig, string, error) +} + +func fetchRelevantAWSRegions(ctx context.Context, authClient databaseGetter, discoveryConfigsClient discoveryConfigLister) ([]string, error) { + regionsSet := make(map[string]struct{}) + + // Collect Regions from Database resources. + databases, err := authClient.GetDatabases(ctx) + if err != nil { + return nil, trace.Wrap(err) + } + + for _, resource := range databases { + regionsSet[resource.GetAWS().Region] = struct{}{} + regionsSet[resource.GetAllLabels()[types.DiscoveryLabelRegion]] = struct{}{} + } + + // Iterate over all DatabaseServices and fetch their AWS Region in the matchers. + var nextPageKey string + for { + req := &proto.ListResourcesRequest{ + ResourceType: types.KindDatabaseService, + Limit: defaults.MaxIterationLimit, + StartKey: nextPageKey, + Labels: map[string]string{types.AWSOIDCAgentLabel: types.True}, + } + page, err := client.GetResourcePage[types.DatabaseService](ctx, authClient, req) + if err != nil { + return nil, trace.Wrap(err) + } + + maps.Copy(regionsSet, extractRegionsFromDatabaseServicesPage(page.Resources)) + + if page.NextKey == "" { + break + } + nextPageKey = page.NextKey + } + + // Iterate over all DiscoveryConfigs and fetch their AWS Region in AWS Matchers. + nextPageKey = "" + for { + resp, respNextPageKey, err := discoveryConfigsClient.ListDiscoveryConfigs(ctx, defaults.MaxIterationLimit, nextPageKey) + if err != nil { + return nil, trace.Wrap(err) + } + + maps.Copy(regionsSet, extractRegionsFromDiscoveryConfigPage(resp)) + + if respNextPageKey == "" { + break + } + nextPageKey = respNextPageKey + } + + // Drop any invalid region. + ret := make([]string, 0, len(regionsSet)) + for region := range regionsSet { + if aws.IsValidRegion(region) == nil { + ret = append(ret, region) + } + } + + return ret, nil +} + +func extractRegionsFromDatabaseServicesPage(dbServices []types.DatabaseService) map[string]struct{} { + regionsSet := make(map[string]struct{}) + for _, resource := range dbServices { + for _, matcher := range resource.GetResourceMatchers() { + if matcher.Labels == nil { + continue + } + for labelKey, labelValues := range *matcher.Labels { + if labelKey != types.DiscoveryLabelRegion { + continue + } + for _, labelValue := range labelValues { + regionsSet[labelValue] = struct{}{} + } + } + } + } + + return regionsSet +} + +func extractRegionsFromDiscoveryConfigPage(discoveryConfigs []*discoveryconfig.DiscoveryConfig) map[string]struct{} { + regionsSet := make(map[string]struct{}) + + for _, dc := range discoveryConfigs { + for _, awsMatcher := range dc.Spec.AWS { + for _, region := range awsMatcher.Regions { + regionsSet[region] = struct{}{} + } + } + } + + return regionsSet +} + +type deployedDatabaseServiceLister interface { + ListDeployedDatabaseServices(ctx context.Context, in *integrationv1.ListDeployedDatabaseServicesRequest, opts ...grpc.CallOption) (*integrationv1.ListDeployedDatabaseServicesResponse, error) +} + +func listDeployedDatabaseServices(ctx context.Context, + logger *slog.Logger, + integrationName string, + regions []string, + awsOIDCClient deployedDatabaseServiceLister, +) ([]ui.AWSOIDCDeployedDatabaseService, error) { + var services []ui.AWSOIDCDeployedDatabaseService + for _, region := range regions { + var nextToken string + for { + resp, err := awsOIDCClient.ListDeployedDatabaseServices(ctx, &integrationv1.ListDeployedDatabaseServicesRequest{ + Integration: integrationName, + Region: region, + NextToken: nextToken, + }) + if err != nil { + return nil, trace.Wrap(err) + } + + for _, deployedDatabaseService := range resp.DeployedDatabaseServices { + matchingLabels, err := matchingLabelsFromDeployedService(deployedDatabaseService) + if err != nil { + logger.WarnContext(ctx, "Failed to obtain teleport config string from ECS Service", + "ecs_service", deployedDatabaseService.ServiceDashboardUrl, + "error", err, + ) + } + validTeleportConfigFound := err == nil + + services = append(services, ui.AWSOIDCDeployedDatabaseService{ + Name: deployedDatabaseService.Name, + DashboardURL: deployedDatabaseService.ServiceDashboardUrl, + MatchingLabels: matchingLabels, + ValidTeleportConfig: validTeleportConfigFound, + }) + } + + if resp.NextToken == "" { + break + } + nextToken = resp.NextToken + } + } + return services, nil +} + +func matchingLabelsFromDeployedService(deployedDatabaseService *integrationv1.DeployedDatabaseService) ([]libui.Label, error) { + commandArgs := deployedDatabaseService.ContainerCommand + // This command is what starts the teleport agent in the ECS Service Fargate container. + // See deployservice.go/upsertTask for details. + // It is expected to have at least 3 values, even if dumb-init is removed in the future. + if len(commandArgs) < 3 { + return nil, trace.BadParameter("unexpected command size, expected at least 3 args, got %d", len(commandArgs)) + } + + // The command should have a --config-string flag and then the teleport's base64 encoded configuration as argument + teleportConfigStringFlagIdx := slices.Index(commandArgs, "--config-string") + if teleportConfigStringFlagIdx == -1 { + return nil, trace.BadParameter("missing --config-string flag in container command") + } + if len(commandArgs) < teleportConfigStringFlagIdx+1 { + return nil, trace.BadParameter("missing --config-string argument in container command") + } + teleportConfigString := commandArgs[teleportConfigStringFlagIdx+1] + + labelMatchers, err := deployserviceconfig.ParseResourceLabelMatchers(teleportConfigString) + if err != nil { + return nil, trace.Wrap(err) + } + + var matchingLabels []libui.Label + for labelKey, labelValues := range labelMatchers { + for _, labelValue := range labelValues { + matchingLabels = append(matchingLabels, libui.Label{ + Name: labelKey, + Value: labelValue, + }) + } + } + + return matchingLabels, nil +} + // awsOIDCConfigureDeployServiceIAM returns a script that configures the required IAM permissions to enable the usage of DeployService action. func (h *Handler) awsOIDCConfigureDeployServiceIAM(w http.ResponseWriter, r *http.Request, p httprouter.Params) (any, error) { ctx := r.Context() diff --git a/lib/web/integrations_awsoidc_test.go b/lib/web/integrations_awsoidc_test.go index 9b2660fe36e99..b8414570999dc 100644 --- a/lib/web/integrations_awsoidc_test.go +++ b/lib/web/integrations_awsoidc_test.go @@ -23,6 +23,7 @@ import ( "encoding/base64" "fmt" "net/url" + "strconv" "strings" "testing" @@ -31,13 +32,18 @@ import ( "github.com/google/uuid" "github.com/gravitational/trace" "github.com/stretchr/testify/require" + "google.golang.org/grpc" "github.com/gravitational/teleport/api" "github.com/gravitational/teleport/api/client/proto" integrationv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/integration/v1" "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/api/types/discoveryconfig" "github.com/gravitational/teleport/lib/integrations/awsoidc" + "github.com/gravitational/teleport/lib/integrations/awsoidc/deployserviceconfig" "github.com/gravitational/teleport/lib/services" + libui "github.com/gravitational/teleport/lib/ui" + "github.com/gravitational/teleport/lib/utils" "github.com/gravitational/teleport/lib/web/ui" ) @@ -1146,3 +1152,331 @@ func TestAWSOIDCAppAccessAppServerCreationDeletion(t *testing.T) { require.NoError(t, err) }) } + +type mockDeployedDatabaseServices struct { + integration string + servicesPerRegion map[string][]*integrationv1.DeployedDatabaseService +} + +func (m *mockDeployedDatabaseServices) ListDeployedDatabaseServices(ctx context.Context, in *integrationv1.ListDeployedDatabaseServicesRequest, opts ...grpc.CallOption) (*integrationv1.ListDeployedDatabaseServicesResponse, error) { + const pageSize = 10 + ret := &integrationv1.ListDeployedDatabaseServicesResponse{} + if in.Integration != m.integration { + return ret, nil + } + + services := m.servicesPerRegion[in.Region] + if len(services) == 0 { + return ret, nil + } + + requestedPage := 1 + totalResources := len(services) + + if in.NextToken != "" { + currentMarker, err := strconv.Atoi(in.NextToken) + if err != nil { + return nil, trace.Wrap(err) + } + requestedPage = currentMarker + } + + sliceStart := pageSize * (requestedPage - 1) + sliceEnd := pageSize * requestedPage + if sliceEnd > totalResources { + sliceEnd = totalResources + } + + ret.DeployedDatabaseServices = services[sliceStart:sliceEnd] + if sliceEnd < totalResources { + ret.NextToken = strconv.Itoa(requestedPage + 1) + } + + return ret, nil +} + +func TestAWSOIDCListDeployedDatabaseServices(t *testing.T) { + ctx := context.Background() + logger := utils.NewSlogLoggerForTests() + + for _, tt := range []struct { + name string + integration string + regions []string + servicesPerRegion func(t *testing.T) map[string][]*integrationv1.DeployedDatabaseService + expectedServices func(t *testing.T) []ui.AWSOIDCDeployedDatabaseService + }{ + { + name: "valid", + integration: "my-integration", + regions: []string{"us-west-2"}, + servicesPerRegion: func(t *testing.T) map[string][]*integrationv1.DeployedDatabaseService { + command := buildCommandDeployedDatabaseService(t, true, types.Labels{"vpc": []string{"vpc1", "vpc2"}}) + return map[string][]*integrationv1.DeployedDatabaseService{ + "us-west-2": dummyDeployedDatabaseServices(1, command), + } + }, + expectedServices: func(t *testing.T) []ui.AWSOIDCDeployedDatabaseService { + return []ui.AWSOIDCDeployedDatabaseService{{ + Name: "database-service-vpc-0", + DashboardURL: "url", + ValidTeleportConfig: true, + MatchingLabels: []libui.Label{ + {Name: "vpc", Value: "vpc1"}, + {Name: "vpc", Value: "vpc2"}, + }, + }} + }, + }, + { + name: "no regions", + integration: "my-integration", + regions: []string{}, + servicesPerRegion: func(t *testing.T) map[string][]*integrationv1.DeployedDatabaseService { + return make(map[string][]*integrationv1.DeployedDatabaseService) + }, + expectedServices: func(t *testing.T) []ui.AWSOIDCDeployedDatabaseService { return nil }, + }, + { + name: "no services", + integration: "my-integration", + regions: []string{"us-west-2"}, + servicesPerRegion: func(t *testing.T) map[string][]*integrationv1.DeployedDatabaseService { + return make(map[string][]*integrationv1.DeployedDatabaseService) + }, + expectedServices: func(t *testing.T) []ui.AWSOIDCDeployedDatabaseService { return nil }, + }, + { + name: "services exist but for another region", + integration: "my-integration", + regions: []string{"us-west-2"}, + servicesPerRegion: func(t *testing.T) map[string][]*integrationv1.DeployedDatabaseService { + return map[string][]*integrationv1.DeployedDatabaseService{ + "us-west-1": dummyDeployedDatabaseServices(1, []string{}), + } + }, + expectedServices: func(t *testing.T) []ui.AWSOIDCDeployedDatabaseService { return nil }, + }, + { + name: "services exist for multiple regions", + integration: "my-integration", + regions: []string{"us-west-2"}, + servicesPerRegion: func(t *testing.T) map[string][]*integrationv1.DeployedDatabaseService { + command := buildCommandDeployedDatabaseService(t, true, types.Labels{"vpc": []string{"vpc1", "vpc2"}}) + return map[string][]*integrationv1.DeployedDatabaseService{ + "us-west-1": dummyDeployedDatabaseServices(1, command), + "us-west-2": dummyDeployedDatabaseServices(1, command), + } + }, + expectedServices: func(t *testing.T) []ui.AWSOIDCDeployedDatabaseService { + return []ui.AWSOIDCDeployedDatabaseService{{ + Name: "database-service-vpc-0", + DashboardURL: "url", + ValidTeleportConfig: true, + MatchingLabels: []libui.Label{ + {Name: "vpc", Value: "vpc1"}, + {Name: "vpc", Value: "vpc2"}, + }, + }} + }, + }, + { + name: "service exist but has invalid configuration", + integration: "my-integration", + regions: []string{"us-west-2"}, + servicesPerRegion: func(t *testing.T) map[string][]*integrationv1.DeployedDatabaseService { + command := buildCommandDeployedDatabaseService(t, false, nil) + return map[string][]*integrationv1.DeployedDatabaseService{ + "us-west-2": dummyDeployedDatabaseServices(1, command), + } + }, + expectedServices: func(t *testing.T) []ui.AWSOIDCDeployedDatabaseService { + return []ui.AWSOIDCDeployedDatabaseService{{ + Name: "database-service-vpc-0", + DashboardURL: "url", + ValidTeleportConfig: false, + }} + }, + }, + { + name: "service exist but was changed and --config-string argument is missing", + integration: "my-integration", + regions: []string{"us-west-2"}, + servicesPerRegion: func(t *testing.T) map[string][]*integrationv1.DeployedDatabaseService { + command := buildCommandDeployedDatabaseService(t, true, types.Labels{"vpc": []string{"vpc1", "vpc2"}}) + command = command[:len(command)-1] + return map[string][]*integrationv1.DeployedDatabaseService{ + "us-west-2": dummyDeployedDatabaseServices(1, command), + } + }, + expectedServices: func(t *testing.T) []ui.AWSOIDCDeployedDatabaseService { + return []ui.AWSOIDCDeployedDatabaseService{{ + Name: "database-service-vpc-0", + DashboardURL: "url", + ValidTeleportConfig: false, + }} + }, + }, + { + name: "service exist but was changed and --config-string flag is missing", + integration: "my-integration", + regions: []string{"us-west-2"}, + servicesPerRegion: func(t *testing.T) map[string][]*integrationv1.DeployedDatabaseService { + command := buildCommandDeployedDatabaseService(t, true, types.Labels{"vpc": []string{"vpc1", "vpc2"}}) + command[1] = "--no-config-string" + return map[string][]*integrationv1.DeployedDatabaseService{ + "us-west-2": dummyDeployedDatabaseServices(1, command), + } + }, + expectedServices: func(t *testing.T) []ui.AWSOIDCDeployedDatabaseService { + return []ui.AWSOIDCDeployedDatabaseService{{ + Name: "database-service-vpc-0", + DashboardURL: "url", + ValidTeleportConfig: false, + }} + }, + }, + { + name: "supports pagination", + integration: "my-integration", + regions: []string{"us-west-2"}, + servicesPerRegion: func(t *testing.T) map[string][]*integrationv1.DeployedDatabaseService { + command := buildCommandDeployedDatabaseService(t, true, types.Labels{"vpc": []string{"vpc1", "vpc2"}}) + return map[string][]*integrationv1.DeployedDatabaseService{ + "us-west-2": dummyDeployedDatabaseServices(1_024, command), + } + }, + expectedServices: func(t *testing.T) []ui.AWSOIDCDeployedDatabaseService { + var ret []ui.AWSOIDCDeployedDatabaseService + for i := 0; i < 1_024; i++ { + ret = append(ret, ui.AWSOIDCDeployedDatabaseService{ + Name: fmt.Sprintf("database-service-vpc-%d", i), + DashboardURL: "url", + ValidTeleportConfig: true, + MatchingLabels: []libui.Label{ + {Name: "vpc", Value: "vpc1"}, + {Name: "vpc", Value: "vpc2"}, + }, + }) + } + return ret + }, + }, + } { + t.Run(tt.name, func(t *testing.T) { + clt := &mockDeployedDatabaseServices{ + integration: tt.integration, + servicesPerRegion: tt.servicesPerRegion(t), + } + actual, err := listDeployedDatabaseServices(ctx, logger, tt.integration, tt.regions, clt) + require.NoError(t, err) + expected := tt.expectedServices(t) + require.Equal(t, expected, actual) + }) + } +} + +func buildCommandDeployedDatabaseService(t *testing.T, valid bool, matchingLabels types.Labels) []string { + t.Helper() + if !valid { + return []string{"not valid"} + } + + ret, err := deployserviceconfig.GenerateTeleportConfigString("host", "token", matchingLabels) + require.NoError(t, err) + + return []string{"start", "--config-string", ret} +} + +func dummyDeployedDatabaseServices(count int, command []string) []*integrationv1.DeployedDatabaseService { + var ret []*integrationv1.DeployedDatabaseService + for i := 0; i < count; i++ { + ret = append(ret, &integrationv1.DeployedDatabaseService{ + Name: fmt.Sprintf("database-service-vpc-%d", i), + ServiceDashboardUrl: "url", + ContainerEntryPoint: []string{"teleport"}, + ContainerCommand: command, + }) + } + return ret +} + +func TestFetchRelevantAWSRegions(t *testing.T) { + ctx := context.Background() + + t.Run("resources do not provide any region", func(t *testing.T) { + clt := &mockRelevantAWSRegionsClient{ + databaseServices: &proto.ListResourcesResponse{ + Resources: []*proto.PaginatedResource{}, + }, + databases: make([]types.Database, 0), + discoveryConfigs: make([]*discoveryconfig.DiscoveryConfig, 0), + } + gotRegions, err := fetchRelevantAWSRegions(ctx, clt, clt) + require.NoError(t, err) + require.Empty(t, gotRegions) + }) + + t.Run("resources provide multiple regions", func(t *testing.T) { + clt := &mockRelevantAWSRegionsClient{ + databaseServices: &proto.ListResourcesResponse{ + Resources: []*proto.PaginatedResource{{Resource: &proto.PaginatedResource_DatabaseService{ + DatabaseService: &types.DatabaseServiceV1{Spec: types.DatabaseServiceSpecV1{ + ResourceMatchers: []*types.DatabaseResourceMatcher{ + {Labels: &types.Labels{"region": []string{"us-east-1"}}}, + {Labels: &types.Labels{"region": []string{"us-east-2"}}}, + }, + }}, + }}}, + }, + databases: []types.Database{ + &types.DatabaseV3{Spec: types.DatabaseSpecV3{AWS: types.AWS{Region: "us-west-1"}}}, + &types.DatabaseV3{Metadata: types.Metadata{Labels: map[string]string{"region": "us-west-2"}}}, + }, + discoveryConfigs: []*discoveryconfig.DiscoveryConfig{{ + Spec: discoveryconfig.Spec{AWS: []types.AWSMatcher{{ + Regions: []string{"eu-west-1", "eu-west-2"}, + }}}, + }}, + } + gotRegions, err := fetchRelevantAWSRegions(ctx, clt, clt) + require.NoError(t, err) + expectedRegions := []string{"us-east-1", "us-east-2", "us-west-1", "us-west-2", "eu-west-1", "eu-west-2"} + require.ElementsMatch(t, expectedRegions, gotRegions) + }) + + t.Run("invalid regions are ignored", func(t *testing.T) { + clt := &mockRelevantAWSRegionsClient{ + databaseServices: &proto.ListResourcesResponse{ + Resources: []*proto.PaginatedResource{}, + }, + databases: []types.Database{ + &types.DatabaseV3{Spec: types.DatabaseSpecV3{AWS: types.AWS{Region: "us-west-1"}}}, + &types.DatabaseV3{Metadata: types.Metadata{Labels: map[string]string{"region": "bad-region"}}}, + }, + discoveryConfigs: make([]*discoveryconfig.DiscoveryConfig, 0), + } + gotRegions, err := fetchRelevantAWSRegions(ctx, clt, clt) + require.NoError(t, err) + expectedRegions := []string{"us-west-1"} + require.ElementsMatch(t, expectedRegions, gotRegions) + }) +} + +type mockRelevantAWSRegionsClient struct { + databaseServices *proto.ListResourcesResponse + databases []types.Database + discoveryConfigs []*discoveryconfig.DiscoveryConfig +} + +func (m *mockRelevantAWSRegionsClient) GetResources(ctx context.Context, req *proto.ListResourcesRequest) (*proto.ListResourcesResponse, error) { + return m.databaseServices, nil +} + +func (m *mockRelevantAWSRegionsClient) GetDatabases(context.Context) ([]types.Database, error) { + return m.databases, nil +} + +func (m *mockRelevantAWSRegionsClient) ListDiscoveryConfigs(ctx context.Context, pageSize int, nextToken string) ([]*discoveryconfig.DiscoveryConfig, string, error) { + return m.discoveryConfigs, "", nil +} diff --git a/lib/web/ui/integration.go b/lib/web/ui/integration.go index b08143978df21..3614a51d09a7f 100644 --- a/lib/web/ui/integration.go +++ b/lib/web/ui/integration.go @@ -371,6 +371,26 @@ type AWSOIDCDeployDatabaseServiceResponse struct { ClusterDashboardURL string `json:"clusterDashboardUrl"` } +// AWSOIDCDeployedDatabaseService represents a Teleport Database Service that is deployed in Amazon ECS. +type AWSOIDCDeployedDatabaseService struct { + // Name is the ECS Service name. + Name string `json:"name,omitempty"` + // DashboardURL is the link to the ECS Service in Amazon Web Console. + DashboardURL string `json:"dashboardUrl,omitempty"` + // ValidTeleportConfig returns whether this ECS Service has a valid Teleport Configuration for a deployed Database Service. + // ECS Services with non-valid configuration require the user to take action on them. + // No MatchingLabels are returned with an invalid configuration. + ValidTeleportConfig bool `json:"validTeleportConfig,omitempty"` + // MatchingLabels are the labels that are used by the Teleport Database Service to know which databases it should proxy. + MatchingLabels []ui.Label `json:"matchingLabels,omitempty"` +} + +// AWSOIDCListDeployedDatabaseServiceResponse is a list of Teleport Database Services that are deployed as ECS Services. +type AWSOIDCListDeployedDatabaseServiceResponse struct { + // Services are the ECS Services. + Services []AWSOIDCDeployedDatabaseService `json:"services"` +} + // AWSOIDCEnrollEKSClustersRequest is a request to ListEKSClusters using the AWS OIDC Integration. type AWSOIDCEnrollEKSClustersRequest struct { // Region is the AWS Region. From d0f95933305bcc17d8e15dd327749b50567e29d7 Mon Sep 17 00:00:00 2001 From: Bartosz Leper Date: Fri, 20 Dec 2024 11:04:57 +0100 Subject: [PATCH 02/46] Move standard editor to its own directory (#50349) --- .../teleport/src/Roles/RoleEditor/RoleEditor.story.tsx | 2 +- .../teleport/src/Roles/RoleEditor/RoleEditor.test.tsx | 2 +- web/packages/teleport/src/Roles/RoleEditor/RoleEditor.tsx | 4 ++-- .../{ => StandardEditor}/RequiresResetToStandard.tsx | 0 .../RoleEditor/{ => StandardEditor}/StandardEditor.test.tsx | 0 .../RoleEditor/{ => StandardEditor}/StandardEditor.tsx | 6 ++---- .../RoleEditor/{ => StandardEditor}/standardmodel.test.ts | 0 .../Roles/RoleEditor/{ => StandardEditor}/standardmodel.ts | 0 .../src/Roles/RoleEditor/{ => StandardEditor}/validation.ts | 0 .../Roles/RoleEditor/{ => StandardEditor}/withDefaults.ts | 0 10 files changed, 6 insertions(+), 8 deletions(-) rename web/packages/teleport/src/Roles/RoleEditor/{ => StandardEditor}/RequiresResetToStandard.tsx (100%) rename web/packages/teleport/src/Roles/RoleEditor/{ => StandardEditor}/StandardEditor.test.tsx (100%) rename web/packages/teleport/src/Roles/RoleEditor/{ => StandardEditor}/StandardEditor.tsx (99%) rename web/packages/teleport/src/Roles/RoleEditor/{ => StandardEditor}/standardmodel.test.ts (100%) rename web/packages/teleport/src/Roles/RoleEditor/{ => StandardEditor}/standardmodel.ts (100%) rename web/packages/teleport/src/Roles/RoleEditor/{ => StandardEditor}/validation.ts (100%) rename web/packages/teleport/src/Roles/RoleEditor/{ => StandardEditor}/withDefaults.ts (100%) diff --git a/web/packages/teleport/src/Roles/RoleEditor/RoleEditor.story.tsx b/web/packages/teleport/src/Roles/RoleEditor/RoleEditor.story.tsx index 14efa8fc69588..9fc5ba22f78ce 100644 --- a/web/packages/teleport/src/Roles/RoleEditor/RoleEditor.story.tsx +++ b/web/packages/teleport/src/Roles/RoleEditor/RoleEditor.story.tsx @@ -30,7 +30,7 @@ import { YamlSupportedResourceKind } from 'teleport/services/yaml/types'; import { Access } from 'teleport/services/user'; import useResources from 'teleport/components/useResources'; -import { withDefaults } from './withDefaults'; +import { withDefaults } from './StandardEditor/withDefaults'; import { RoleEditor } from './RoleEditor'; import { RoleEditorDialog } from './RoleEditorDialog'; diff --git a/web/packages/teleport/src/Roles/RoleEditor/RoleEditor.test.tsx b/web/packages/teleport/src/Roles/RoleEditor/RoleEditor.test.tsx index 0fa38219bd398..aeec6288e2431 100644 --- a/web/packages/teleport/src/Roles/RoleEditor/RoleEditor.test.tsx +++ b/web/packages/teleport/src/Roles/RoleEditor/RoleEditor.test.tsx @@ -31,7 +31,7 @@ import { import { CaptureEvent, userEventService } from 'teleport/services/userEvent'; import { RoleEditor, RoleEditorProps } from './RoleEditor'; -import { defaultOptions, withDefaults } from './withDefaults'; +import { defaultOptions, withDefaults } from './StandardEditor/withDefaults'; // The Ace editor is very difficult to deal with in tests, especially that for // handling its state, we are using input event, which is asynchronous. Thus, diff --git a/web/packages/teleport/src/Roles/RoleEditor/RoleEditor.tsx b/web/packages/teleport/src/Roles/RoleEditor/RoleEditor.tsx index 729360ecb4254..2eeb3dc9cc9ad 100644 --- a/web/packages/teleport/src/Roles/RoleEditor/RoleEditor.tsx +++ b/web/packages/teleport/src/Roles/RoleEditor/RoleEditor.tsx @@ -32,11 +32,11 @@ import { newRole, StandardEditorModel, roleToRoleEditorModel as roleToRoleEditorModel, -} from './standardmodel'; +} from './StandardEditor/standardmodel'; import { YamlEditorModel } from './yamlmodel'; import { EditorTab } from './EditorTabs'; import { EditorHeader } from './EditorHeader'; -import { StandardEditor } from './StandardEditor'; +import { StandardEditor } from './StandardEditor/StandardEditor'; import { YamlEditor } from './YamlEditor'; export type RoleEditorProps = { diff --git a/web/packages/teleport/src/Roles/RoleEditor/RequiresResetToStandard.tsx b/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/RequiresResetToStandard.tsx similarity index 100% rename from web/packages/teleport/src/Roles/RoleEditor/RequiresResetToStandard.tsx rename to web/packages/teleport/src/Roles/RoleEditor/StandardEditor/RequiresResetToStandard.tsx diff --git a/web/packages/teleport/src/Roles/RoleEditor/StandardEditor.test.tsx b/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/StandardEditor.test.tsx similarity index 100% rename from web/packages/teleport/src/Roles/RoleEditor/StandardEditor.test.tsx rename to web/packages/teleport/src/Roles/RoleEditor/StandardEditor/StandardEditor.test.tsx diff --git a/web/packages/teleport/src/Roles/RoleEditor/StandardEditor.tsx b/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/StandardEditor.tsx similarity index 99% rename from web/packages/teleport/src/Roles/RoleEditor/StandardEditor.tsx rename to web/packages/teleport/src/Roles/RoleEditor/StandardEditor/StandardEditor.tsx index 49c5c5e30feb8..1661eb14974f4 100644 --- a/web/packages/teleport/src/Roles/RoleEditor/StandardEditor.tsx +++ b/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/StandardEditor.tsx @@ -46,13 +46,12 @@ import { import { SlideTabs } from 'design/SlideTabs'; import { RadioGroup } from 'design/RadioGroup'; import Select from 'shared/components/Select'; - import { components, MultiValueProps } from 'react-select'; - import { Role, RoleWithYaml } from 'teleport/services/resources'; import { LabelsInput } from 'teleport/components/LabelsInput'; +import { FieldMultiInput } from 'shared/components/FieldMultiInput/FieldMultiInput'; -import { FieldMultiInput } from '../../../../shared/components/FieldMultiInput/FieldMultiInput'; +import { EditorSaveCancelButton } from '../Shared'; import { roleEditorModelToRole, @@ -96,7 +95,6 @@ import { WindowsDesktopSpecValidationResult, AccessRuleValidationResult, } from './validation'; -import { EditorSaveCancelButton } from './Shared'; import { RequiresResetToStandard } from './RequiresResetToStandard'; export type StandardEditorProps = { diff --git a/web/packages/teleport/src/Roles/RoleEditor/standardmodel.test.ts b/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/standardmodel.test.ts similarity index 100% rename from web/packages/teleport/src/Roles/RoleEditor/standardmodel.test.ts rename to web/packages/teleport/src/Roles/RoleEditor/StandardEditor/standardmodel.test.ts diff --git a/web/packages/teleport/src/Roles/RoleEditor/standardmodel.ts b/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/standardmodel.ts similarity index 100% rename from web/packages/teleport/src/Roles/RoleEditor/standardmodel.ts rename to web/packages/teleport/src/Roles/RoleEditor/StandardEditor/standardmodel.ts diff --git a/web/packages/teleport/src/Roles/RoleEditor/validation.ts b/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/validation.ts similarity index 100% rename from web/packages/teleport/src/Roles/RoleEditor/validation.ts rename to web/packages/teleport/src/Roles/RoleEditor/StandardEditor/validation.ts diff --git a/web/packages/teleport/src/Roles/RoleEditor/withDefaults.ts b/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/withDefaults.ts similarity index 100% rename from web/packages/teleport/src/Roles/RoleEditor/withDefaults.ts rename to web/packages/teleport/src/Roles/RoleEditor/StandardEditor/withDefaults.ts From 9450343084420202c4bd67de615bfe49aa029fd2 Mon Sep 17 00:00:00 2001 From: Bartosz Leper Date: Fri, 20 Dec 2024 12:47:59 +0100 Subject: [PATCH 03/46] Split the StandardEditor (#50350) --- .../StandardEditor/AccessRules.test.tsx | 85 ++ .../RoleEditor/StandardEditor/AccessRules.tsx | 145 +++ .../StandardEditor/MetadataSection.tsx | 70 ++ .../RoleEditor/StandardEditor/Options.tsx | 236 +++++ .../StandardEditor/Resources.test.tsx | 465 +++++++++ .../RoleEditor/StandardEditor/Resources.tsx | 486 +++++++++ .../StandardEditor/StandardEditor.test.tsx | 553 +--------- .../StandardEditor/StandardEditor.tsx | 944 +----------------- .../StandardEditor/StatefulSection.tsx | 59 ++ .../RoleEditor/StandardEditor/sections.tsx | 130 +++ 10 files changed, 1688 insertions(+), 1485 deletions(-) create mode 100644 web/packages/teleport/src/Roles/RoleEditor/StandardEditor/AccessRules.test.tsx create mode 100644 web/packages/teleport/src/Roles/RoleEditor/StandardEditor/AccessRules.tsx create mode 100644 web/packages/teleport/src/Roles/RoleEditor/StandardEditor/MetadataSection.tsx create mode 100644 web/packages/teleport/src/Roles/RoleEditor/StandardEditor/Options.tsx create mode 100644 web/packages/teleport/src/Roles/RoleEditor/StandardEditor/Resources.test.tsx create mode 100644 web/packages/teleport/src/Roles/RoleEditor/StandardEditor/Resources.tsx create mode 100644 web/packages/teleport/src/Roles/RoleEditor/StandardEditor/StatefulSection.tsx create mode 100644 web/packages/teleport/src/Roles/RoleEditor/StandardEditor/sections.tsx diff --git a/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/AccessRules.test.tsx b/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/AccessRules.test.tsx new file mode 100644 index 0000000000000..1bbd3a5db36c2 --- /dev/null +++ b/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/AccessRules.test.tsx @@ -0,0 +1,85 @@ +/** + * Teleport + * Copyright (C) 2024 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 . + */ + +import { render, screen, userEvent } from 'design/utils/testing'; +import { act } from '@testing-library/react'; +import { Validator } from 'shared/components/Validation'; +import selectEvent from 'react-select-event'; +import { ResourceKind } from 'teleport/services/resources'; + +import { RuleModel } from './standardmodel'; +import { AccessRuleValidationResult, validateAccessRule } from './validation'; +import { AccessRules } from './AccessRules'; +import { StatefulSection } from './StatefulSection'; + +describe('AccessRules', () => { + const setup = () => { + const onChange = jest.fn(); + let validator: Validator; + render( + + component={AccessRules} + defaultValue={[]} + onChange={onChange} + validatorRef={v => { + validator = v; + }} + validate={rules => rules.map(validateAccessRule)} + /> + ); + return { user: userEvent.setup(), onChange, validator }; + }; + + test('editing', async () => { + const { user, onChange } = setup(); + await user.click(screen.getByRole('button', { name: 'Add New' })); + await selectEvent.select(screen.getByLabelText('Resources'), [ + 'db', + 'node', + ]); + await selectEvent.select(screen.getByLabelText('Permissions'), [ + 'list', + 'read', + ]); + expect(onChange).toHaveBeenLastCalledWith([ + { + id: expect.any(String), + resources: [ + { label: ResourceKind.Database, value: 'db' }, + { label: ResourceKind.Node, value: 'node' }, + ], + verbs: [ + { label: 'list', value: 'list' }, + { label: 'read', value: 'read' }, + ], + }, + ] as RuleModel[]); + }); + + test('validation', async () => { + const { user, validator } = setup(); + await user.click(screen.getByRole('button', { name: 'Add New' })); + act(() => validator.validate()); + expect( + screen.getByText('At least one resource kind is required') + ).toBeInTheDocument(); + expect( + screen.getByText('At least one permission is required') + ).toBeInTheDocument(); + }); +}); diff --git a/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/AccessRules.tsx b/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/AccessRules.tsx new file mode 100644 index 0000000000000..78b680e21e8b3 --- /dev/null +++ b/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/AccessRules.tsx @@ -0,0 +1,145 @@ +/** + * Teleport + * Copyright (C) 2024 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 . + */ + +import Flex from 'design/Flex'; + +import { ButtonSecondary } from 'design/Button'; +import { Plus } from 'design/Icon'; +import { + FieldSelect, + FieldSelectCreatable, +} from 'shared/components/FieldSelect'; +import { precomputed } from 'shared/components/Validation/rules'; +import { components, MultiValueProps } from 'react-select'; +import { HoverTooltip } from 'design/Tooltip'; +import styled from 'styled-components'; + +import { AccessRuleValidationResult } from './validation'; +import { + newRuleModel, + ResourceKindOption, + resourceKindOptions, + resourceKindOptionsMap, + RuleModel, + verbOptions, +} from './standardmodel'; +import { Section, SectionProps } from './sections'; + +export function AccessRules({ + value, + isProcessing, + validation, + onChange, +}: SectionProps) { + function addRule() { + onChange?.([...value, newRuleModel()]); + } + function setRule(rule: RuleModel) { + onChange?.(value.map(r => (r.id === rule.id ? rule : r))); + } + function removeRule(id: string) { + onChange?.(value.filter(r => r.id !== id)); + } + return ( + + {value.map((rule, i) => ( + removeRule(rule.id)} + /> + ))} + + + Add New + + + ); +} + +function AccessRule({ + value, + isProcessing, + validation, + onChange, + onRemove, +}: SectionProps & { + onRemove?(): void; +}) { + const { resources, verbs } = value; + return ( +
+ onChange?.({ ...value, resources: r })} + rule={precomputed(validation.fields.resources)} + /> + onChange?.({ ...value, verbs: v })} + rule={precomputed(validation.fields.verbs)} + mb={0} + /> +
+ ); +} + +const ResourceKindSelect = styled( + FieldSelectCreatable +)` + .teleport-resourcekind__value--unknown { + background: ${props => props.theme.colors.interactive.solid.alert.default}; + .react-select__multi-value__label, + .react-select__multi-value__remove { + color: ${props => props.theme.colors.text.primaryInverse}; + } + } +`; + +function ResourceKindMultiValue(props: MultiValueProps) { + if (resourceKindOptionsMap.has(props.data.value)) { + return ; + } + return ( + + + + ); +} diff --git a/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/MetadataSection.tsx b/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/MetadataSection.tsx new file mode 100644 index 0000000000000..605a101964e5e --- /dev/null +++ b/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/MetadataSection.tsx @@ -0,0 +1,70 @@ +/** + * Teleport + * Copyright (C) 2024 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 . + */ + +import FieldInput from 'shared/components/FieldInput'; + +import { precomputed } from 'shared/components/Validation/rules'; + +import { LabelsInput } from 'teleport/components/LabelsInput'; + +import Text from 'design/Text'; + +import { Section, SectionProps } from './sections'; +import { MetadataModel } from './standardmodel'; +import { MetadataValidationResult } from './validation'; + +export const MetadataSection = ({ + value, + isProcessing, + validation, + onChange, +}: SectionProps) => ( +
+ onChange({ ...value, name: e.target.value })} + /> + ) => + onChange({ ...value, description: e.target.value }) + } + /> + + Labels + + onChange?.({ ...value, labels })} + rule={precomputed(validation.fields.labels)} + /> +
+); diff --git a/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/Options.tsx b/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/Options.tsx new file mode 100644 index 0000000000000..cf510a5050aad --- /dev/null +++ b/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/Options.tsx @@ -0,0 +1,236 @@ +/** + * Teleport + * Copyright (C) 2024 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 . + */ + +import Box from 'design/Box'; +import Input from 'design/Input'; +import LabelInput from 'design/LabelInput'; +import { RadioGroup } from 'design/RadioGroup'; +import { H4 } from 'design/Text'; +import { useId } from 'react'; +import styled, { useTheme } from 'styled-components'; + +import Select from 'shared/components/Select'; + +import { SectionProps } from './sections'; +import { + OptionsModel, + requireMFATypeOptions, + sessionRecordingModeOptions, + createHostUserModeOptions, + createDBUserModeOptions, +} from './standardmodel'; + +export function Options({ + value, + isProcessing, + onChange, +}: SectionProps) { + const theme = useTheme(); + const id = useId(); + const maxSessionTTLId = `${id}-max-session-ttl`; + const clientIdleTimeoutId = `${id}-client-idle-timeout`; + const requireMFATypeId = `${id}-require-mfa-type`; + const createHostUserModeId = `${id}-create-host-user-mode`; + const createDBUserModeId = `${id}-create-db-user-mode`; + const defaultSessionRecordingModeId = `${id}-default-session-recording-mode`; + const sshSessionRecordingModeId = `${id}-ssh-session-recording-mode`; + return ( + + Global Settings + + Max Session TTL + onChange({ ...value, maxSessionTTL: e.target.value })} + /> + + + Client Idle Timeout + + + onChange({ ...value, clientIdleTimeout: e.target.value }) + } + /> + + Disconnect When Certificate Expires + onChange({ ...value, disconnectExpiredCert: d })} + /> + + Require Session MFA + onChange?.({ ...value, defaultSessionRecordingMode: m })} + /> + + SSH + + + Create Host User Mode + + onChange?.({ ...value, sshSessionRecordingMode: m })} + /> + + Database + + Create Database User + onChange({ ...value, createDBUser: c })} + /> + + {/* TODO(bl-nero): a bug in YAML unmarshalling backend breaks the + createDBUserMode field. Fix it and add the field here. */} + + Create Database User Mode + + onChange({ ...value, maxSessionTTL: e.target.value })} - /> - - - Client Idle Timeout - - - onChange({ ...value, clientIdleTimeout: e.target.value }) - } - /> - - Disconnect When Certificate Expires - onChange({ ...value, disconnectExpiredCert: d })} - /> - - Require Session MFA - onChange?.({ ...value, defaultSessionRecordingMode: m })} - /> - - SSH - - - Create Host User Mode - - onChange?.({ ...value, sshSessionRecordingMode: m })} - /> - - Database - - Create Database User - onChange({ ...value, createDBUser: c })} - /> - - {/* TODO(bl-nero): a bug in YAML unmarshalling backend breaks the - createDBUserMode field. Fix it and add the field here. */} - - Create Database User Mode - -