Skip to content

Commit

Permalink
AWS OIDC: List Deployed Database Services HTTP API
Browse files Browse the repository at this point in the history
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
  • Loading branch information
marcoandredinis committed Nov 22, 2024
1 parent 46744e1 commit 3dc5a4f
Show file tree
Hide file tree
Showing 6 changed files with 581 additions and 0 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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")
})
}
1 change: 1 addition & 0 deletions lib/web/apiserver.go
Original file line number Diff line number Diff line change
Expand Up @@ -991,6 +991,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))
Expand Down
198 changes: 198 additions & 0 deletions lib/web/integrations_awsoidc.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (
"encoding/base64"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"net/url"
"slices"
Expand All @@ -31,13 +32,15 @@ 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"
"github.com/gravitational/teleport/api/client/proto"
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"
Expand All @@ -48,6 +51,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"
"github.com/gravitational/teleport/lib/utils/oidc"
"github.com/gravitational/teleport/lib/web/scripts/oneoff"
Expand Down Expand Up @@ -253,6 +257,200 @@ 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
}

func fetchRelevantAWSRegions(ctx context.Context,
authClient interface {
GetResources(ctx context.Context, req *proto.ListResourcesRequest) (*proto.ListResourcesResponse, error)
GetDatabases(context.Context) ([]types.Database, error)
},
discoveryConfigsClient interface {
ListDiscoveryConfigs(ctx context.Context, pageSize int, nextToken string) ([]*discoveryconfig.DiscoveryConfig, string, error)
},
) ([]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,
}
page, err := client.GetResourcePage[types.DatabaseService](ctx, authClient, req)
if err != nil {
return nil, trace.Wrap(err)
}
for _, resource := range page.Resources {
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{}{}
}
}
}
}
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)
}

for _, dc := range resp {
for _, awsMatcher := range dc.Spec.AWS {
for _, region := range awsMatcher.Regions {
regionsSet[region] = struct{}{}
}
}
}

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 listDeployedDatabaseServices(ctx context.Context,
logger *slog.Logger,
integrationName string,
regions []string,
awsoidcClient interface {
ListDeployedDatabaseServices(ctx context.Context, in *integrationv1.ListDeployedDatabaseServicesRequest, opts ...grpc.CallOption) (*integrationv1.ListDeployedDatabaseServicesResponse, error)
},
) ([]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 --config-string flag's value is the last argument.
teleportConfigString := commandArgs[len(commandArgs)-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()
Expand Down
Loading

0 comments on commit 3dc5a4f

Please sign in to comment.