mirror of
https://github.com/gravitational/teleport.git
synced 2026-09-24 16:17:11 +08:00
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:
@@ -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
|
||||
@@ -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())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user