Move Identity Center validation tools to OSS (#52239)

In order to provide better validation when installing the IC integration via `tctl`,
this patch moves some IC-specific validation tools from `teleport.e` to
into the OSS repository.
This commit is contained in:
Trent Clarke
2025-02-19 02:48:35 +00:00
committed by GitHub
parent 7249f21181
commit 1364c35d24
5 changed files with 464 additions and 0 deletions
+20
View File
@@ -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
View File
@@ -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
}
@@ -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
View File
@@ -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
}
@@ -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 <http://www.gnu.org/licenses/>.
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)
}
})
}
}