Skip to content
Merged
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
20 changes: 20 additions & 0 deletions lib/aws/identitycenter/doc.go
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.

// 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
99 changes: 99 additions & 0 deletions lib/aws/identitycenter/filters/filters.go
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.

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
}
153 changes: 153 additions & 0 deletions lib/aws/identitycenter/filters/filters_test.go
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.

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())
})
}
}
88 changes: 88 additions & 0 deletions lib/utils/aws/identitycenterutils/scim.go
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.

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.<aws-region>.amazonaws.com/<random-id>/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
}
Loading