From d12554bc8373e1b2bdb8508fb2ef712bc3c7913b Mon Sep 17 00:00:00 2001 From: Marco Dinis Date: Thu, 24 Jul 2025 16:47:28 +0100 Subject: [PATCH] AWS Roles Anywhere: implement profile filters (sync and list profiles method) (#56718) * AWS IAM Roles Anywhere: implement filters for Profile sync and lister * move client out of request * improve godoc for the ListRolesAnywhereProfiles api --- .../integration/v1/awsra_service.pb.go | 3 +- .../integration/v1/awsra_service_grpc.pb.go | 6 + .../integration/v1/awsra_service.proto | 6 +- lib/auth/integration/integrationv1/awsra.go | 4 +- .../integration/integrationv1/awsra_test.go | 64 +++++++++++ lib/auth/integration/integrationv1/service.go | 27 +++++ .../awsra/list_rolesanywhere_profiles.go | 108 +++++++++++++++--- .../awsra/list_rolesanywhere_profiles_test.go | 106 +++++++++++++++++ lib/integrations/awsra/ping.go | 6 +- lib/integrations/awsra/profile_syncer.go | 9 +- lib/web/integrations.go | 14 ++- lib/web/integrations_awsra.go | 5 +- lib/web/integrations_test.go | 9 +- lib/web/ui/integration.go | 37 +++++- 14 files changed, 366 insertions(+), 38 deletions(-) diff --git a/api/gen/proto/go/teleport/integration/v1/awsra_service.pb.go b/api/gen/proto/go/teleport/integration/v1/awsra_service.pb.go index 9b6df70ebac..297b5c4dccd 100644 --- a/api/gen/proto/go/teleport/integration/v1/awsra_service.pb.go +++ b/api/gen/proto/go/teleport/integration/v1/awsra_service.pb.go @@ -263,7 +263,8 @@ type ListRolesAnywhereProfilesRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // Integration is the AWS Roles Anywhere Integration name. Integration string `protobuf:"bytes,1,opt,name=integration,proto3" json:"integration,omitempty"` - // page_size is the size of the page to request. + // page_size is the max size of the page to request. + // Depending on the filters, the actual number of profiles returned may be less than this value. PageSize int32 `protobuf:"varint,2,opt,name=page_size,json=pageSize,proto3" json:"page_size,omitempty"` // next_page_token is the page token. NextPageToken string `protobuf:"bytes,3,opt,name=next_page_token,json=nextPageToken,proto3" json:"next_page_token,omitempty"` diff --git a/api/gen/proto/go/teleport/integration/v1/awsra_service_grpc.pb.go b/api/gen/proto/go/teleport/integration/v1/awsra_service_grpc.pb.go index 485904affa2..33b51e617e3 100644 --- a/api/gen/proto/go/teleport/integration/v1/awsra_service_grpc.pb.go +++ b/api/gen/proto/go/teleport/integration/v1/awsra_service_grpc.pb.go @@ -53,6 +53,9 @@ type AWSRolesAnywhereServiceClient interface { // It uses the following API: // https://docs.aws.amazon.com/rolesanywhere/latest/APIReference/API_ListProfiles.html // https://docs.aws.amazon.com/rolesanywhere/latest/APIReference/API_ListTagsForResource.html + // + // The number of profiles returned is always between 0 and page_size. + // If the number of elements is 0, then there are no more profiles to return and the next page token is empty. ListRolesAnywhereProfiles(ctx context.Context, in *ListRolesAnywhereProfilesRequest, opts ...grpc.CallOption) (*ListRolesAnywhereProfilesResponse, error) } @@ -100,6 +103,9 @@ type AWSRolesAnywhereServiceServer interface { // It uses the following API: // https://docs.aws.amazon.com/rolesanywhere/latest/APIReference/API_ListProfiles.html // https://docs.aws.amazon.com/rolesanywhere/latest/APIReference/API_ListTagsForResource.html + // + // The number of profiles returned is always between 0 and page_size. + // If the number of elements is 0, then there are no more profiles to return and the next page token is empty. ListRolesAnywhereProfiles(context.Context, *ListRolesAnywhereProfilesRequest) (*ListRolesAnywhereProfilesResponse, error) mustEmbedUnimplementedAWSRolesAnywhereServiceServer() } diff --git a/api/proto/teleport/integration/v1/awsra_service.proto b/api/proto/teleport/integration/v1/awsra_service.proto index db17c55aa37..e15effa44e5 100644 --- a/api/proto/teleport/integration/v1/awsra_service.proto +++ b/api/proto/teleport/integration/v1/awsra_service.proto @@ -31,6 +31,9 @@ service AWSRolesAnywhereService { // It uses the following API: // https://docs.aws.amazon.com/rolesanywhere/latest/APIReference/API_ListProfiles.html // https://docs.aws.amazon.com/rolesanywhere/latest/APIReference/API_ListTagsForResource.html + // + // The number of profiles returned is always between 0 and page_size. + // If the number of elements is 0, then there are no more profiles to return and the next page token is empty. rpc ListRolesAnywhereProfiles(ListRolesAnywhereProfilesRequest) returns (ListRolesAnywhereProfilesResponse); } @@ -73,7 +76,8 @@ message AWSRolesAnywherePingResponse { message ListRolesAnywhereProfilesRequest { // Integration is the AWS Roles Anywhere Integration name. string integration = 1; - // page_size is the size of the page to request. + // page_size is the max size of the page to request. + // Depending on the filters, the actual number of profiles returned may be less than this value. int32 page_size = 2; // next_page_token is the page token. string next_page_token = 3; diff --git a/lib/auth/integration/integrationv1/awsra.go b/lib/auth/integration/integrationv1/awsra.go index aebc9941150..891eb8027bf 100644 --- a/lib/auth/integration/integrationv1/awsra.go +++ b/lib/auth/integration/integrationv1/awsra.go @@ -230,7 +230,9 @@ func (s *AWSRolesAnywhereService) ListRolesAnywhereProfiles(ctx context.Context, } profileList, err := awsra.ListRolesAnywhereProfiles(ctx, listRolesAnywhereClient, awsra.ListRolesAnywhereProfilesRequest{ - NextToken: req.GetNextPageToken(), + NextToken: req.GetNextPageToken(), + ProfileNameFilters: req.GetProfileNameFilters(), + PageSize: int(req.GetPageSize()), }) if err != nil { return nil, trace.Wrap(err) diff --git a/lib/auth/integration/integrationv1/awsra_test.go b/lib/auth/integration/integrationv1/awsra_test.go index 1a6d91772c6..f244f0d0e31 100644 --- a/lib/auth/integration/integrationv1/awsra_test.go +++ b/lib/auth/integration/integrationv1/awsra_test.go @@ -100,6 +100,70 @@ func TestGenerateAWSRACredentials(t *testing.T) { }) } +func TestAWSRolesAnywhereProfileSyncFilters(t *testing.T) { + t.Parallel() + clusterName := "test-cluster" + proxyPublicAddr := "example.com:443" + + ca := newCertAuthority(t, types.AWSRACA, clusterName) + ctx, localClient, resourceSvc := initSvc(t, ca, clusterName, proxyPublicAddr) + + ctx = authorizerForDummyUser(t, ctx, types.RoleSpecV6{ + Allow: types.RoleConditions{Rules: []types.Rule{ + {Resources: []string{types.KindIntegration}, Verbs: []string{types.VerbCreate, types.VerbUpdate}}, + }}, + }, localClient) + + integrationWithFilters := func(t *testing.T, integrationName string, filters []string) *types.IntegrationV1 { + ig, err := types.NewIntegrationAWSRA( + types.Metadata{Name: integrationName}, + &types.AWSRAIntegrationSpecV1{ + TrustAnchorARN: "arn:aws:rolesanywhere:eu-west-2:123456789012:trust-anchor/12345678-1234-1234-1234-123456789012", + ProfileSyncConfig: &types.AWSRolesAnywhereProfileSyncConfig{ + Enabled: true, + ProfileARN: "arn:aws:rolesanywhere:eu-west-2:123456789012:profile/12345678-1234-1234-1234-123456789012", + RoleARN: "arn:aws:iam::123456789012:role/SyncRole", + ProfileNameFilters: filters, + }, + }, + ) + require.NoError(t, err) + return ig + } + + t.Run("valid without filters", func(t *testing.T) { + ig := integrationWithFilters(t, "integration1", []string{}) + _, err := resourceSvc.CreateIntegration(ctx, &integrationv1.CreateIntegrationRequest{ + Integration: ig, + }) + require.NoError(t, err) + }) + + t.Run("valid with glob filter", func(t *testing.T) { + ig := integrationWithFilters(t, "integration2", []string{"MyTeam-*"}) + _, err := resourceSvc.CreateIntegration(ctx, &integrationv1.CreateIntegrationRequest{ + Integration: ig, + }) + require.NoError(t, err) + }) + + t.Run("valid with regex filter", func(t *testing.T) { + ig := integrationWithFilters(t, "integration3", []string{"^MyTeam-.*$"}) + _, err := resourceSvc.CreateIntegration(ctx, &integrationv1.CreateIntegrationRequest{ + Integration: ig, + }) + require.NoError(t, err) + }) + + t.Run("invalid with invalid regex", func(t *testing.T) { + ig := integrationWithFilters(t, "integration5", []string{`^[invalid-regex{$`}) + _, err := resourceSvc.CreateIntegration(ctx, &integrationv1.CreateIntegrationRequest{ + Integration: ig, + }) + require.Error(t, err) + }) +} + func TestAWSRolesAnywherePing(t *testing.T) { t.Parallel() clusterName := "test-cluster" diff --git a/lib/auth/integration/integrationv1/service.go b/lib/auth/integration/integrationv1/service.go index b2aa0a2822e..ab1f49e628b 100644 --- a/lib/auth/integration/integrationv1/service.go +++ b/lib/auth/integration/integrationv1/service.go @@ -41,6 +41,7 @@ import ( "github.com/gravitational/teleport/lib/integrations/awsra/createsession" "github.com/gravitational/teleport/lib/modules" "github.com/gravitational/teleport/lib/services" + "github.com/gravitational/teleport/lib/utils" ) // Cache is the subset of the cached resources that the Service queries. @@ -254,6 +255,10 @@ func (s *Service) CreateIntegration(ctx context.Context, req *integrationpb.Crea if errs := validation.IsDNS1035Label(req.GetIntegration().GetName()); len(errs) > 0 { return nil, trace.BadParameter("integration name %q must be a lower case valid DNS subdomain so that it can be used to allow Web/CLI access", req.GetIntegration().GetName()) } + + if err := validateAWSRolesAnywhereProfileFilters(req.Integration); err != nil { + return nil, trace.Wrap(err) + } } ig, err := s.backend.CreateIntegration(ctx, req.Integration) @@ -302,6 +307,10 @@ func (s *Service) UpdateIntegration(ctx context.Context, req *integrationpb.Upda return nil, trace.Wrap(err) } + if err := validateAWSRolesAnywhereProfileFilters(req.Integration); err != nil { + return nil, trace.Wrap(err) + } + if err := s.maybeUpdateStaticCredentials(ctx, req.Integration); err != nil { return nil, trace.Wrap(err) } @@ -341,6 +350,24 @@ func (s *Service) UpdateIntegration(ctx context.Context, req *integrationpb.Upda return igV1, nil } +func validateAWSRolesAnywhereProfileFilters(ig types.Integration) error { + rolesAnywhereSpec := ig.GetAWSRolesAnywhereIntegrationSpec() + if rolesAnywhereSpec == nil { + return nil + } + + if rolesAnywhereSpec.ProfileSyncConfig == nil { + return nil + } + + for _, profileNameFilter := range rolesAnywhereSpec.ProfileSyncConfig.ProfileNameFilters { + if _, err := utils.CompileExpression(profileNameFilter); err != nil { + return trace.BadParameter("invalid filter %q, use glob-like matching or regex by adding the anchors (eg, ^regex$): %v", profileNameFilter, err) + } + } + return nil +} + // DeleteIntegration removes the specified Integration resource. // // This RPC may remove multiple resources in the backend: diff --git a/lib/integrations/awsra/list_rolesanywhere_profiles.go b/lib/integrations/awsra/list_rolesanywhere_profiles.go index bdb3efaa413..d86f169e99d 100644 --- a/lib/integrations/awsra/list_rolesanywhere_profiles.go +++ b/lib/integrations/awsra/list_rolesanywhere_profiles.go @@ -26,6 +26,7 @@ import ( "github.com/gravitational/trace" integrationv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/integration/v1" + "github.com/gravitational/teleport/lib/utils" ) // ListRolesAnywhereProfilesRequest contains the required fields to list the Roles Anywhere Profiles in Amazon IAM. @@ -35,6 +36,20 @@ type ListRolesAnywhereProfilesRequest struct { NextToken string // PageSize is the maximum number of records to retrieve. PageSize int + // ProfileNameFilters is a list of filters applied to the profile name. + // Only matching profiles will be synchronized as application servers. + // If empty, no filtering is applied. + // + // Filters can be globs, for example: + // + // profile* + // *name* + // + // Or regexes if they're prefixed and suffixed with ^ and $, for example: + // + // ^profile.*$ + // ^.*name.*$ + ProfileNameFilters []string } // ListRolesAnywhereProfilesResponse contains a page of Roles Anywhere Profiles. @@ -81,7 +96,12 @@ func ListRolesAnywhereProfiles(ctx context.Context, clt RolesAnywhereProfilesLis if req.NextToken != "" { nextToken = aws.String(req.NextToken) } - profiles, nextPageToken, err := listRolesAnywhereProfilesPage(ctx, clt, nextToken, req.PageSize) + listReq := listRolesAnywhereProfilesRequest{ + nextPage: nextToken, + pageSize: req.PageSize, + filters: req.ProfileNameFilters, + } + profiles, nextPageToken, err := listRolesAnywhereProfilesPage(ctx, clt, listReq) if err != nil { return nil, trace.Wrap(err) } @@ -92,28 +112,64 @@ func ListRolesAnywhereProfiles(ctx context.Context, clt RolesAnywhereProfilesLis }, nil } -func listRolesAnywhereProfilesPage(ctx context.Context, raClient RolesAnywhereProfilesLister, nextPage *string, pageSize int) ([]*integrationv1.RolesAnywhereProfile, *string, error) { +type listRolesAnywhereProfilesRequest struct { + nextPage *string + pageSize int + filters []string +} + +func listRolesAnywhereProfilesPage(ctx context.Context, raClient RolesAnywhereProfilesLister, req listRolesAnywhereProfilesRequest) (ret []*integrationv1.RolesAnywhereProfile, nextToken *string, err error) { + var pageSizeRequest *int32 + if req.pageSize > 0 { + pageSizeRequest = aws.Int32(int32(req.pageSize)) + } + + nextPage := req.nextPage + + for { + profilesListResp, err := raClient.ListProfiles(ctx, &rolesanywhere.ListProfilesInput{ + NextToken: nextPage, + PageSize: pageSizeRequest, + }) + if err != nil { + return nil, nil, trace.Wrap(err) + } + + profilesAsProto, err := convertRolesAnywhereProfilePageToProto(ctx, profilesListResp, raClient, req) + if err != nil { + return nil, nil, trace.Wrap(err) + } + ret = append(ret, profilesAsProto...) + + // If no profile was added (because of filters), and there's more resources, fetch the next page. + // This ensures that the client doesn't receive an empty page when there are more resources to fetch. + nextPage = profilesListResp.NextToken + if len(ret) == 0 && aws.ToString(nextPage) != "" { + continue + } + break + } + + return ret, nextPage, nil +} + +func convertRolesAnywhereProfilePageToProto(ctx context.Context, profilesListResp *rolesanywhere.ListProfilesOutput, raClient RolesAnywhereProfilesLister, req listRolesAnywhereProfilesRequest) ([]*integrationv1.RolesAnywhereProfile, error) { var ret []*integrationv1.RolesAnywhereProfile - var pageSizeRequest *int32 - if pageSize > 0 { - pageSizeRequest = aws.Int32(int32(pageSize)) - } - - profilesListResp, err := raClient.ListProfiles(ctx, &rolesanywhere.ListProfilesInput{ - NextToken: nextPage, - PageSize: pageSizeRequest, - }) - if err != nil { - return nil, nil, trace.Wrap(err) - } - for _, profile := range profilesListResp.Profiles { + matches, err := profileNameMatchesFilters(aws.ToString(profile.Name), req.filters) + if err != nil { + return nil, trace.Wrap(err) + } + if !matches { + continue + } + profileTags, err := raClient.ListTagsForResource(ctx, &rolesanywhere.ListTagsForResourceInput{ ResourceArn: profile.ProfileArn, }) if err != nil { - return nil, nil, trace.Wrap(err) + return nil, trace.Wrap(err) } labels := make(map[string]string, len(profileTags.Tags)) @@ -131,5 +187,23 @@ func listRolesAnywhereProfilesPage(ctx context.Context, raClient RolesAnywherePr }) } - return ret, profilesListResp.NextToken, nil + return ret, nil +} + +func profileNameMatchesFilters(profileName string, filters []string) (bool, error) { + // If no filters are provided, all profiles match. + if len(filters) == 0 { + return true, nil + } + + for _, filter := range filters { + matches, err := utils.MatchString(profileName, filter) + if err != nil { + return false, trace.Wrap(err, "error parsing filter: %s", filter) + } + if matches { + return true, nil + } + } + return false, nil } diff --git a/lib/integrations/awsra/list_rolesanywhere_profiles_test.go b/lib/integrations/awsra/list_rolesanywhere_profiles_test.go index 15262365d0a..2a55dd5761c 100644 --- a/lib/integrations/awsra/list_rolesanywhere_profiles_test.go +++ b/lib/integrations/awsra/list_rolesanywhere_profiles_test.go @@ -25,7 +25,10 @@ import ( "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/rolesanywhere" ratypes "github.com/aws/aws-sdk-go-v2/service/rolesanywhere/types" + "github.com/google/uuid" "github.com/stretchr/testify/require" + + integrationv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/integration/v1" ) func TestListRolesAnywhereProfiles(t *testing.T) { @@ -63,6 +66,109 @@ func TestListRolesAnywhereProfiles(t *testing.T) { require.Len(t, resp.Profiles, 3) } +func TestListRolesAnywhereProfilesPage(t *testing.T) { + rolesAnywhereProfileWithName := func(name string) ratypes.ProfileDetail { + return ratypes.ProfileDetail{ + Name: aws.String(name), + ProfileArn: aws.String(uuid.NewString()), + } + } + + for _, tt := range []struct { + name string + req listRolesAnywhereProfilesRequest + clientMock RolesAnywhereProfilesLister + expectedResp func(t *testing.T, page []*integrationv1.RolesAnywhereProfile) + expectedErrCheck require.ErrorAssertionFunc + }{ + { + name: "no resources", + req: listRolesAnywhereProfilesRequest{}, + clientMock: &mockIAMRolesAnywhere{}, + expectedResp: func(t *testing.T, page []*integrationv1.RolesAnywhereProfile) { + require.Empty(t, page) + }, + expectedErrCheck: require.NoError, + }, + { + name: "some resources", + req: listRolesAnywhereProfilesRequest{}, + clientMock: &mockIAMRolesAnywhere{ + profilesByID: map[string]ratypes.ProfileDetail{ + "id1": rolesAnywhereProfileWithName("ExampleProfile"), + }, + }, + expectedResp: func(t *testing.T, page []*integrationv1.RolesAnywhereProfile) { + require.Len(t, page, 1) + require.Equal(t, "ExampleProfile", page[0].Name) + }, + expectedErrCheck: require.NoError, + }, + { + name: "with filters using glob", + req: listRolesAnywhereProfilesRequest{ + filters: []string{"TeamB-*"}, + }, + clientMock: &mockIAMRolesAnywhere{ + profilesByID: map[string]ratypes.ProfileDetail{ + "id1": rolesAnywhereProfileWithName("TeamA-subteam1"), + "id2": rolesAnywhereProfileWithName("TeamA-subteam2"), + "id3": rolesAnywhereProfileWithName("TeamA-subteam3"), + + "id4": rolesAnywhereProfileWithName("TeamB-subteam1"), + "id5": rolesAnywhereProfileWithName("TeamB-subteam2"), + "id6": rolesAnywhereProfileWithName("TeamB-subteam3"), + }, + }, + expectedResp: func(t *testing.T, page []*integrationv1.RolesAnywhereProfile) { + require.Len(t, page, 3) + profile := page[0] + require.NotEmpty(t, profile.Arn) + require.Contains(t, profile.Name, "TeamB-subteam") + }, + expectedErrCheck: require.NoError, + }, + { + name: "with filters using regex", + req: listRolesAnywhereProfilesRequest{ + filters: []string{`^Team[A,B]{1}\-subteam\d$`}, + }, + clientMock: &mockIAMRolesAnywhere{ + profilesByID: map[string]ratypes.ProfileDetail{ + "id1": rolesAnywhereProfileWithName("TeamA-subteam1"), + "id2": rolesAnywhereProfileWithName("TeamA-subteam2"), + "id3": rolesAnywhereProfileWithName("TeamA-subteam3"), + + "id4": rolesAnywhereProfileWithName("TeamB-subteam1"), + "id5": rolesAnywhereProfileWithName("TeamB-subteam2"), + "id6": rolesAnywhereProfileWithName("TeamB-subteam3"), + + "id7": rolesAnywhereProfileWithName("TeamC-subteam1"), + "id8": rolesAnywhereProfileWithName("TeamC-subteam2"), + "id9": rolesAnywhereProfileWithName("TeamC-subteam3"), + + "id10": rolesAnywhereProfileWithName("AnotherTeam"), + }, + }, + expectedResp: func(t *testing.T, page []*integrationv1.RolesAnywhereProfile) { + require.Len(t, page, 6) + profileNames := make([]string, 0, len(page)) + for _, profile := range page { + profileNames = append(profileNames, profile.Name) + } + require.Contains(t, profileNames, "TeamA-subteam1") + }, + expectedErrCheck: require.NoError, + }, + } { + t.Run(tt.name, func(t *testing.T) { + resp, _, err := listRolesAnywhereProfilesPage(t.Context(), tt.clientMock, tt.req) + tt.expectedErrCheck(t, err) + tt.expectedResp(t, resp) + }) + } +} + type mockListRolesAnywhereProfiles struct { profiles []ratypes.ProfileDetail } diff --git a/lib/integrations/awsra/ping.go b/lib/integrations/awsra/ping.go index 4f19c6c0fb6..7152d0b4661 100644 --- a/lib/integrations/awsra/ping.go +++ b/lib/integrations/awsra/ping.go @@ -80,8 +80,10 @@ func Ping(ctx context.Context, clt PingClient) (*PingResponse, error) { profileCounter := 0 var nextToken *string for { - const useAPIDefaultPageSize = 0 - profiles, nextPageToken, err := listRolesAnywhereProfilesPage(ctx, clt, nextToken, useAPIDefaultPageSize) + listReq := listRolesAnywhereProfilesRequest{ + nextPage: nextToken, + } + profiles, nextPageToken, err := listRolesAnywhereProfilesPage(ctx, clt, listReq) if err != nil { errs = append(errs, err) break diff --git a/lib/integrations/awsra/profile_syncer.go b/lib/integrations/awsra/profile_syncer.go index 3acd0a15437..5b286432a29 100644 --- a/lib/integrations/awsra/profile_syncer.go +++ b/lib/integrations/awsra/profile_syncer.go @@ -388,10 +388,15 @@ func syncProfileForIntegration(ctx context.Context, params AWSRolesAnywherProfil return ret } + profileNameFilters := integration.GetAWSRolesAnywhereIntegrationSpec().ProfileSyncConfig.ProfileNameFilters + var nextPage *string for { - const useAPIDefaultPageSize = 0 - profilesListResp, respNextToken, err := listRolesAnywhereProfilesPage(ctx, raClient, nextPage, useAPIDefaultPageSize) + listReq := listRolesAnywhereProfilesRequest{ + nextPage: nextPage, + filters: profileNameFilters, + } + profilesListResp, respNextToken, err := listRolesAnywhereProfilesPage(ctx, raClient, listReq) if err != nil { ret.setupError = trace.Wrap(err) return ret diff --git a/lib/web/integrations.go b/lib/web/integrations.go index 8be873a8322..99eb36edc34 100644 --- a/lib/web/integrations.go +++ b/lib/web/integrations.go @@ -110,9 +110,10 @@ func (h *Handler) integrationsCreate(w http.ResponseWriter, r *http.Request, p h }, &types.AWSRAIntegrationSpecV1{ TrustAnchorARN: req.Integration.AWSRA.TrustAnchorARN, ProfileSyncConfig: &types.AWSRolesAnywhereProfileSyncConfig{ - Enabled: req.Integration.AWSRA.ProfileSyncConfig.Enabled, - ProfileARN: req.Integration.AWSRA.ProfileSyncConfig.ProfileARN, - RoleARN: req.Integration.AWSRA.ProfileSyncConfig.RoleARN, + Enabled: req.Integration.AWSRA.ProfileSyncConfig.Enabled, + ProfileARN: req.Integration.AWSRA.ProfileSyncConfig.ProfileARN, + RoleARN: req.Integration.AWSRA.ProfileSyncConfig.RoleARN, + ProfileNameFilters: req.Integration.AWSRA.ProfileSyncConfig.ProfileNameFilters, }, }) if err != nil { @@ -212,9 +213,10 @@ func (h *Handler) integrationsUpdate(w http.ResponseWriter, r *http.Request, p h spec := integration.GetAWSRolesAnywhereIntegrationSpec() spec.TrustAnchorARN = req.AWSRA.TrustAnchorARN spec.ProfileSyncConfig = &types.AWSRolesAnywhereProfileSyncConfig{ - Enabled: req.AWSRA.ProfileSyncConfig.Enabled, - ProfileARN: req.AWSRA.ProfileSyncConfig.ProfileARN, - RoleARN: req.AWSRA.ProfileSyncConfig.RoleARN, + Enabled: req.AWSRA.ProfileSyncConfig.Enabled, + ProfileARN: req.AWSRA.ProfileSyncConfig.ProfileARN, + RoleARN: req.AWSRA.ProfileSyncConfig.RoleARN, + ProfileNameFilters: req.AWSRA.ProfileSyncConfig.ProfileNameFilters, ProfileAcceptsRoleSessionName: spec.ProfileSyncConfig.ProfileAcceptsRoleSessionName, } diff --git a/lib/web/integrations_awsra.go b/lib/web/integrations_awsra.go index 2b8dd638a55..57896861eab 100644 --- a/lib/web/integrations_awsra.go +++ b/lib/web/integrations_awsra.go @@ -223,8 +223,9 @@ func (h *Handler) awsRolesAnywhereListProfiles(w http.ResponseWriter, r *http.Re } listResp, err := clt.IntegrationAWSRolesAnywhereClient().ListRolesAnywhereProfiles(ctx, &integrationv1.ListRolesAnywhereProfilesRequest{ - Integration: integrationName, - NextPageToken: req.StartKey, + Integration: integrationName, + NextPageToken: req.StartKey, + ProfileNameFilters: req.Filters, }) if err != nil { return nil, trace.Wrap(err) diff --git a/lib/web/integrations_test.go b/lib/web/integrations_test.go index 2c7e87328a0..2f01ca26ed3 100644 --- a/lib/web/integrations_test.go +++ b/lib/web/integrations_test.go @@ -171,9 +171,10 @@ func TestIntegrationsCRUDRolesAnywhere(t *testing.T) { AWSRA: &ui.IntegrationAWSRASpec{ TrustAnchorARN: updatedTrustAnchor, ProfileSyncConfig: ui.AWSRAProfileSync{ - Enabled: true, - ProfileARN: syncProfileARN, - RoleARN: syncRoleARN, + Enabled: true, + ProfileARN: syncProfileARN, + RoleARN: syncRoleARN, + ProfileNameFilters: []string{"ExposedProfile-*"}, }, }, } @@ -197,6 +198,8 @@ func TestIntegrationsCRUDRolesAnywhere(t *testing.T) { require.True(t, integrationObject.AWSRA.ProfileSyncConfig.Enabled) require.Equal(t, syncProfileARN, integrationObject.AWSRA.ProfileSyncConfig.ProfileARN) require.Equal(t, syncRoleARN, integrationObject.AWSRA.ProfileSyncConfig.RoleARN) + require.Len(t, integrationObject.AWSRA.ProfileSyncConfig.ProfileNameFilters, 1) + require.Equal(t, "ExposedProfile-*", integrationObject.AWSRA.ProfileSyncConfig.ProfileNameFilters[0]) // Delete Integration err = wPack.server.Auth().DeleteIntegration(ctx, integrationName) diff --git a/lib/web/ui/integration.go b/lib/web/ui/integration.go index 4e38df58d7e..5bcb2ff584c 100644 --- a/lib/web/ui/integration.go +++ b/lib/web/ui/integration.go @@ -68,6 +68,21 @@ type AWSRAProfileSync struct { // RoleARN is the ARN of the IAM Role that is used to sync profiles. RoleARN string `json:"roleArn"` + + // ProfileNameFilters are the filters applied to the profiles. + // Only matching profiles will be synchronized as application servers. + // If empty, no filtering is applied. + // + // Filters can be globs, for example: + // + // profile* + // *name* + // + // Or regexes if they're prefixed and suffixed with ^ and $, for example: + // + // ^profile.*$ + // ^.*name.*$ + ProfileNameFilters []string `json:"filters"` } // CheckAndSetDefaults for the aws oidc integration spec. @@ -355,9 +370,10 @@ func MakeIntegration(ig types.Integration) (*Integration, error) { ret.AWSRA = &IntegrationAWSRASpec{ TrustAnchorARN: spec.TrustAnchorARN, ProfileSyncConfig: AWSRAProfileSync{ - Enabled: spec.ProfileSyncConfig.Enabled, - ProfileARN: spec.ProfileSyncConfig.ProfileARN, - RoleARN: spec.ProfileSyncConfig.RoleARN, + Enabled: spec.ProfileSyncConfig.Enabled, + ProfileARN: spec.ProfileSyncConfig.ProfileARN, + RoleARN: spec.ProfileSyncConfig.RoleARN, + ProfileNameFilters: spec.ProfileSyncConfig.ProfileNameFilters, }, } } @@ -699,4 +715,19 @@ type AWSRolesAnywhereListProfilesRequest struct { // StartKey is the token to be used to fetch the next page. // If empty, the first page is fetched. StartKey string `json:"startKey"` + + // Filters are the filters applied to the profiles. + // Only matching profiles will be synchronized as application servers. + // If empty, no filtering is applied. + // + // Filters can be globs, for example: + // + // profile* + // *name* + // + // Or regexes if they're prefixed and suffixed with ^ and $, for example: + // + // ^profile.*$ + // ^.*name.*$ + Filters []string `json:"filters"` }