diff --git a/lib/aws/identitycenter/doc.go b/lib/aws/identitycenter/doc.go new file mode 100644 index 0000000000000..54d9bc044f8ac --- /dev/null +++ b/lib/aws/identitycenter/doc.go @@ -0,0 +1,20 @@ +// Teleport +// Copyright (C) 2025 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 . + +// Package identitycenter contains code used by the Identity Center +// integration that also needs to be visible to OSS builds, for example in +// `tctl` +package identitycenter diff --git a/lib/aws/identitycenter/filters/filters.go b/lib/aws/identitycenter/filters/filters.go new file mode 100644 index 0000000000000..3d8fa8e413fe3 --- /dev/null +++ b/lib/aws/identitycenter/filters/filters.go @@ -0,0 +1,99 @@ +// Teleport +// Copyright (C) 2025 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 . + +package filters + +import ( + "context" + "fmt" + "log/slog" + + "github.com/gravitational/trace" + + "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/lib/utils" +) + +// New creates a new Filters instance from the supplied [types.AWSICResourceFilter]s. +func New(filters []*types.AWSICResourceFilter) (Filters, error) { + out := Filters(filters) + if err := out.validate(); err != nil { + return nil, trace.Wrap(err) + } + return out, nil +} + +// Filters is a collection of filters. +type Filters []*types.AWSICResourceFilter + +// validate validates the filters. +func (f Filters) validate() error { + for _, v := range f { + switch v.Include.(type) { + case *types.AWSICResourceFilter_NameRegex: + if _, err := utils.CompileExpression(v.GetNameRegex()); err != nil { + return trace.Wrap(err) + } + } + } + return nil +} + +// Params is a collection of filter parameters. +// It contains the items to filter, and functions to get the name and ID of an item. +type Params[T any] struct { + // Items is the items to filter. + Items []T + // GetName is a function that gets the name of an item. + GetName func(T) string + // GetID is a function that gets the ID of an item. + GetID func(T) string +} + +// Filter filters items based on the filters and parameters. +func Filter[T any](filters Filters, params Params[T]) []T { + if len(filters) == 0 { + return params.Items + } + var out []T + for _, item := range params.Items { + if matchesFilters(item, filters, params) { + out = append(out, item) + } + } + return out +} + +func matchesFilters[T any](item T, filters Filters, params Params[T]) bool { + for _, filter := range filters { + switch v := filter.Include.(type) { + case *types.AWSICResourceFilter_Id: + if params.GetID != nil && params.GetID(item) == v.Id { + return true + } + case *types.AWSICResourceFilter_NameRegex: + if params.GetName != nil { + compiledFilter, err := utils.CompileExpression(v.NameRegex) + if err == nil && compiledFilter.MatchString(params.GetName(item)) { + return true + } + } + default: + slog.ErrorContext(context.Background(), "AWSSyncFilter unsupported filter type encountered. Filter will be skipped.", "type", fmt.Sprintf("%T", v)) + } + } + return false +} diff --git a/lib/aws/identitycenter/filters/filters_test.go b/lib/aws/identitycenter/filters/filters_test.go new file mode 100644 index 0000000000000..955da863797ff --- /dev/null +++ b/lib/aws/identitycenter/filters/filters_test.go @@ -0,0 +1,153 @@ +// Teleport +// Copyright (C) 2025 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 . + +package filters + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/gravitational/teleport/api/types" +) + +func TestFilterItems(t *testing.T) { + type TestItem struct { + ID string + Name string + } + + items := []TestItem{ + {ID: "1", Name: "apple"}, + {ID: "2", Name: "banana"}, + {ID: "3", Name: "cherry"}, + {ID: "4", Name: "avocado"}, + } + + tests := []struct { + name string + filters Filters + params Params[TestItem] + expected []TestItem + }{ + { + name: "Filter by ID", + filters: Filters{&types.AWSICResourceFilter{Include: &types.AWSICResourceFilter_Id{Id: "2"}}}, + params: Params[TestItem]{ + Items: items, + GetName: func(item TestItem) string { + return item.Name + }, + GetID: func(item TestItem) string { + return item.ID + }, + }, + expected: []TestItem{{ID: "2", Name: "banana"}}, + }, + { + name: "Filter by Name", + filters: Filters{&types.AWSICResourceFilter{Include: &types.AWSICResourceFilter_NameRegex{NameRegex: "a*"}}}, + params: Params[TestItem]{ + Items: items, + GetName: func(item TestItem) string { + return item.Name + }, + GetID: nil, + }, + expected: []TestItem{{ID: "1", Name: "apple"}, {ID: "4", Name: "avocado"}}, + }, + { + name: "Exclude All", + filters: Filters{&types.AWSICResourceFilter{Include: &types.AWSICResourceFilter_NameRegex{NameRegex: "teleport.internal/exclude_all"}}}, + params: Params[TestItem]{ + Items: items, + GetName: func(item TestItem) string { + return item.Name + }, + GetID: func(item TestItem) string { + return item.ID + }, + }, + expected: nil, + }, + { + name: "No Filters", + filters: Filters{}, + params: Params[TestItem]{ + Items: items, + GetName: func(item TestItem) string { + return item.Name + }, + GetID: func(item TestItem) string { + return item.ID + }, + }, + expected: items, + }, + { + name: "Multiple Filters", + filters: Filters{ + &types.AWSICResourceFilter{Include: &types.AWSICResourceFilter_Id{Id: "2"}}, + &types.AWSICResourceFilter{Include: &types.AWSICResourceFilter_NameRegex{NameRegex: "a*"}}, + &types.AWSICResourceFilter{Include: &types.AWSICResourceFilter_Id{Id: "4"}}, + }, + params: Params[TestItem]{ + Items: items, + GetName: func(item TestItem) string { + return item.Name + }, + GetID: func(item TestItem) string { + return item.ID + }, + }, + expected: []TestItem{ + {ID: "1", Name: "apple"}, + {ID: "2", Name: "banana"}, + {ID: "4", Name: "avocado"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := Filter(tt.filters, tt.params) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestPoorlyFormedFiltersAreAnError(t *testing.T) { + testCases := []struct { + name string + filters Filters + errorAssertion require.ErrorAssertionFunc + }{ + { + name: "Bad regex", + filters: Filters{ + &types.AWSICResourceFilter{Include: &types.AWSICResourceFilter_NameRegex{NameRegex: "^[)$"}}, + }, + errorAssertion: require.Error, + }, + } + + for _, test := range testCases { + t.Run(test.name, func(t *testing.T) { + test.errorAssertion(t, test.filters.validate()) + }) + } +} diff --git a/lib/utils/aws/identitycenterutils/scim.go b/lib/utils/aws/identitycenterutils/scim.go new file mode 100644 index 0000000000000..b7fa36c3d6be1 --- /dev/null +++ b/lib/utils/aws/identitycenterutils/scim.go @@ -0,0 +1,88 @@ +// Teleport +// Copyright (C) 2025 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 . + +package identitycenterutils + +import ( + "fmt" + "net/url" + "regexp" + "strings" + + "github.com/gravitational/trace" + + awsutils "github.com/gravitational/teleport/lib/utils/aws" +) + +// matchAWSICEndpointIDField matches an alphanumeric value separated by a hyphen. +var matchAWSICEndpointIDField = regexp.MustCompile(`^[a-zA-Z0-9-]*$`).MatchString + +// EnsureSCIMEndpoint validates dynamic fields of SCIM base URL and returns +// a new base URL constructed from the validated fields. +// +// E.g. valid SCIM base URL: +// "https://scim.ca-central-1.amazonaws.com/bdh6a5e3698-0fc6-4232-a028-fea1a99ff77a/scim/v2". +// Dynamic field includes the AWS region and a random ID field: +// "https://scim..amazonaws.com//scim/v2" +// Region value is validated against known AWS regions and the random ID field is +// validated against an alphanumeric with hyphen regexp. +// Note: The random ID field looks like a UUID field but does not confirm to +// standard UUID format defined in RFC 4122. +func EnsureSCIMEndpoint(u string) (string, error) { + baseURL, err := url.ParseRequestURI(u) + if err != nil { + return "", trace.BadParameter("invalid SCIM endpoint format: %s", err.Error()) + } + if baseURL.Scheme != "https" { + return "", trace.BadParameter("url scheme must be https") + } + + domainParts := strings.Split(baseURL.Hostname(), ".") + if len(domainParts) != 4 { + return "", trace.BadParameter("invalid SCIM endpoint format") + } + if domainParts[0] != "scim" { + return "", trace.BadParameter("unrecognized SCIM endpoint") + } + region := domainParts[1] + if !awsutils.IsKnownRegion(region) { + return "", trace.BadParameter("region %q is invalid", region) + } + if domainParts[2] != "amazonaws" || domainParts[3] != "com" { + return "", trace.BadParameter("SCIM endpoint must be of 'amazonaws.com' domain") + } + + pathParts := strings.Split(baseURL.Path, "/") + if len(pathParts) != 4 { + return "", trace.BadParameter("invalid SCIM endpoint format") + } + if !matchAWSICEndpointIDField(pathParts[1]) { + return "", trace.BadParameter("invalid SCIM endpoint format") + } + if pathParts[2] != "scim" { + return "", trace.BadParameter("unrecognized SCIM endpoint") + } + if pathParts[3] != "v2" { + return "", trace.BadParameter("only v2 SCIM endpoint is supported") + } + + newBaseURL := url.URL{ + Scheme: "https", + Host: fmt.Sprintf("scim.%s.amazonaws.com", region), + Path: fmt.Sprintf("%s/scim/v2", pathParts[1]), + } + return newBaseURL.String(), nil +} diff --git a/lib/utils/aws/identitycenterutils/scim_test.go b/lib/utils/aws/identitycenterutils/scim_test.go new file mode 100644 index 0000000000000..e2d8b966635fc --- /dev/null +++ b/lib/utils/aws/identitycenterutils/scim_test.go @@ -0,0 +1,104 @@ +// Teleport +// Copyright (C) 2025 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 . + +package identitycenterutils + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestEnsureSCIMEndpoint(t *testing.T) { + testCases := []struct { + name string + input string + expected string + errorAssertion require.ErrorAssertionFunc + }{ + { + name: "missing scheme", + input: "scim.ca-central-1.amazonaws.com/bdh6a5e3698-0fc6-4232-a028-fea1a99ff77a/scim/v2", + errorAssertion: require.Error, + }, + { + name: "non https scheme", + input: "http://scim.ca-central-1.amazonaws.com/bdh6a5e3698-0fc6-4232-a028-fea1a99ff77a/scim/v2", + errorAssertion: require.Error, + }, + { + name: "tcp scheme", + input: "tcp://scim.ca-central-1.amazonaws.com/bdh6a5e3698-0fc6-4232-a028-fea1a99ff77a/scim/v2", + errorAssertion: require.Error, + }, + { + name: "invalid region", + input: "https://scim.test.amazonaws.com/bdh6a5e3698-0fc6-4232-a028-fea1a99ff77a/scim/v2", + errorAssertion: require.Error, + }, + { + name: "invalid region - with a domain", + input: "https://scim.anotherdomain:8080/.amazonaws.com/bdh6a5e3698-0fc6-4232-a028-fea1a99ff77a/scim/v2", + errorAssertion: require.Error, + }, + { + name: "invalid random id - contains URL", + input: "https://scim.ca-central-1.amazonaws.com/http://example.com/scim/v2", + errorAssertion: require.Error, + }, + { + name: "invalid random id - contains another host", + input: "https://scim.ca-central-1.amazonaws.com/.anotherdomain.com/scim/v2", + errorAssertion: require.Error, + }, + { + name: "invalid path", + input: "scim.ca-central-1.amazonaws.com/@example.com", + errorAssertion: require.Error, + }, + { + name: "non-scim subdomain", + input: "https://example.ca-central-1.example.com/bdh6a5e3698-0fc6-4232-a028-fea1a99ff77a/scim/v2", + errorAssertion: require.Error, + }, + { + name: "non-v2 version", + input: "https://scim.ca-central-1.amazonaws.com/bdh6a5e3698-0fc6-4232-a028-fea1a99ff77a/scim/v10", + errorAssertion: require.Error, + }, + { + name: "non-amazonaws.com domain", + input: "https://scim.ca-central-1.amazonaws.io/bdh6a5e3698-0fc6-4232-a028-fea1a99ff77a/scim/v2", + errorAssertion: require.Error, + }, + { + name: "valid base URL", + input: "https://scim.ca-central-1.amazonaws.com/bdh6a5e3698-0fc6-4232-a028-fea1a99ff77a/scim/v2", + expected: "https://scim.ca-central-1.amazonaws.com/bdh6a5e3698-0fc6-4232-a028-fea1a99ff77a/scim/v2", + errorAssertion: require.NoError, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + ensuredURL, err := EnsureSCIMEndpoint(tc.input) + tc.errorAssertion(t, err) + if tc.expected != "" { + require.Equal(t, tc.expected, ensuredURL) + } + }) + } +}