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)
+ }
+ })
+ }
+}