Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

AWS OIDC: List Deployed Database Services HTTP API #49352

Open
wants to merge 3 commits into
base: marco/awsoidc-listdatabaseservices-impl
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -990,6 +990,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.GET("/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
227 changes: 227 additions & 0 deletions lib/web/integrations_awsoidc.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ import (
"encoding/base64"
"encoding/json"
"fmt"
"log/slog"
"maps"
"net/http"
"net/url"
"slices"
Expand All @@ -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"
Expand All @@ -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"
Expand All @@ -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"
Expand Down Expand Up @@ -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,
marcoandredinis marked this conversation as resolved.
Show resolved Hide resolved
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()
Expand Down
Loading
Loading