mirror of
https://github.com/gravitational/teleport.git
synced 2026-08-29 05:27:37 +08:00
af259fe33f
* Add shared server-side username display resolver * move functions into users * wording
553 lines
16 KiB
Go
553 lines
16 KiB
Go
/*
|
|
* Teleport
|
|
* Copyright (C) 2023 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 services
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"slices"
|
|
"testing"
|
|
|
|
"github.com/google/go-cmp/cmp"
|
|
"github.com/gravitational/trace"
|
|
saml2 "github.com/russellhaering/gosaml2"
|
|
samltypes "github.com/russellhaering/gosaml2/types"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
apidefaults "github.com/gravitational/teleport/api/defaults"
|
|
"github.com/gravitational/teleport/api/types"
|
|
)
|
|
|
|
func TestTraits(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var tests = []struct {
|
|
traitName string
|
|
}{
|
|
// Windows trait names are URLs.
|
|
{
|
|
traitName: "http://schemas.microsoft.com/ws/2008/06/identity/claims/windowsaccountname",
|
|
},
|
|
// Simple strings are the most common trait names.
|
|
{
|
|
traitName: "user-groups",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
user := &types.UserV2{
|
|
Kind: types.KindUser,
|
|
Version: types.V2,
|
|
Metadata: types.Metadata{
|
|
Name: "foo",
|
|
Namespace: apidefaults.Namespace,
|
|
},
|
|
Spec: types.UserSpecV2{
|
|
Traits: map[string][]string{
|
|
tt.traitName: {"foo"},
|
|
},
|
|
},
|
|
}
|
|
|
|
data, err := json.Marshal(user)
|
|
require.NoError(t, err)
|
|
|
|
_, err = UnmarshalUser(data)
|
|
require.NoError(t, err)
|
|
}
|
|
}
|
|
|
|
type oidcInput struct {
|
|
comment string
|
|
claims map[string]any
|
|
expectedRoles []string
|
|
warnings []string
|
|
}
|
|
|
|
var oidcTestCases = []struct {
|
|
comment string
|
|
mappings []types.ClaimMapping
|
|
inputs []oidcInput
|
|
}{
|
|
{
|
|
comment: "no mappings",
|
|
inputs: []oidcInput{
|
|
{
|
|
comment: "no match",
|
|
claims: map[string]any{"a": "b"},
|
|
expectedRoles: nil,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
comment: "simple mappings",
|
|
mappings: []types.ClaimMapping{
|
|
{Claim: "role", Value: "admin", Roles: []string{"admin", "bob"}},
|
|
{Claim: "role", Value: "user", Roles: []string{"user"}},
|
|
},
|
|
inputs: []oidcInput{
|
|
{
|
|
comment: "no match",
|
|
claims: map[string]any{"a": "b"},
|
|
expectedRoles: nil,
|
|
},
|
|
{
|
|
comment: "no value match",
|
|
claims: map[string]any{"role": "b"},
|
|
expectedRoles: nil,
|
|
},
|
|
{
|
|
comment: "direct admin value match",
|
|
claims: map[string]any{"role": "admin"},
|
|
expectedRoles: []string{"admin", "bob"},
|
|
},
|
|
{
|
|
comment: "direct user value match",
|
|
claims: map[string]any{"role": "user"},
|
|
expectedRoles: []string{"user"},
|
|
},
|
|
{
|
|
comment: "direct user value match with array",
|
|
claims: map[string]any{"role": []string{"user"}},
|
|
expectedRoles: []string{"user"},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
comment: "regexp mappings match",
|
|
mappings: []types.ClaimMapping{
|
|
{Claim: "role", Value: "^admin-(.*)$", Roles: []string{"role-$1", "bob"}},
|
|
},
|
|
inputs: []oidcInput{
|
|
{
|
|
comment: "no match",
|
|
claims: map[string]any{"a": "b"},
|
|
expectedRoles: nil,
|
|
},
|
|
{
|
|
comment: "no match - subprefix",
|
|
claims: map[string]any{"role": "adminz"},
|
|
expectedRoles: nil,
|
|
},
|
|
{
|
|
comment: "value with capture match",
|
|
claims: map[string]any{"role": "admin-hello"},
|
|
expectedRoles: []string{"role-hello", "bob"},
|
|
},
|
|
{
|
|
comment: "multiple value with capture match, deduplication",
|
|
claims: map[string]any{"role": []string{"admin-hello", "admin-ola"}},
|
|
expectedRoles: []string{"role-hello", "bob", "role-ola"},
|
|
},
|
|
{
|
|
comment: "first matches, second does not",
|
|
claims: map[string]any{"role": []string{"hello", "admin-ola"}},
|
|
expectedRoles: []string{"role-ola", "bob"},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
comment: "regexp compilation",
|
|
mappings: []types.ClaimMapping{
|
|
{Claim: "role", Value: `^admin-(?!)$`, Roles: []string{"admin"}}, // "?!" is invalid.
|
|
{Claim: "role", Value: "^admin-(.*)$", Roles: []string{"role-$1", "bob"}},
|
|
{Claim: "role", Value: `^admin2-(?!)$`, Roles: []string{"admin2"}}, // "?!" is invalid.
|
|
},
|
|
inputs: []oidcInput{
|
|
{
|
|
comment: "invalid regexp",
|
|
claims: map[string]any{"role": []string{"admin-hello", "dev"}},
|
|
expectedRoles: []string{"role-hello", "bob"},
|
|
warnings: []string{
|
|
`case-insensitive expression "^admin-(?!)$" is not a valid regexp`,
|
|
`case-insensitive expression "^admin2-(?!)$" is not a valid regexp`,
|
|
},
|
|
},
|
|
{
|
|
comment: "regexp are not compiled if not needed",
|
|
claims: map[string]any{},
|
|
expectedRoles: nil,
|
|
// if the regexp were compiled, we would have the same warnings as above
|
|
warnings: nil,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
comment: "empty expands are skipped",
|
|
mappings: []types.ClaimMapping{
|
|
{Claim: "role", Value: "^admin-(.*)$", Roles: []string{"$2", "bob"}},
|
|
},
|
|
inputs: []oidcInput{
|
|
{
|
|
comment: "value with capture match",
|
|
claims: map[string]any{"role": "admin-hello"},
|
|
expectedRoles: []string{"bob"},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
comment: "glob wildcard match",
|
|
mappings: []types.ClaimMapping{
|
|
{Claim: "role", Value: "*", Roles: []string{"admin"}},
|
|
},
|
|
inputs: []oidcInput{
|
|
{
|
|
comment: "empty value match",
|
|
claims: map[string]any{"role": ""},
|
|
expectedRoles: []string{"admin"},
|
|
},
|
|
{
|
|
comment: "any value match",
|
|
claims: map[string]any{"role": "zz"},
|
|
expectedRoles: []string{"admin"},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
comment: "Whitespace/dashes",
|
|
mappings: []types.ClaimMapping{
|
|
{Claim: "groups", Value: "DemoCorp - Backend Engineers", Roles: []string{"backend"}},
|
|
{Claim: "groups", Value: "DemoCorp - SRE Managers", Roles: []string{"approver"}},
|
|
{Claim: "groups", Value: "DemoCorp - SRE", Roles: []string{"approver"}},
|
|
{Claim: "groups", Value: "DemoCorp Infrastructure", Roles: []string{"approver", "backend"}},
|
|
},
|
|
inputs: []oidcInput{
|
|
{
|
|
comment: "Matches multiple groups",
|
|
claims: map[string]any{
|
|
"groups": []string{"DemoCorp - Backend Engineers", "DemoCorp Infrastructure"},
|
|
},
|
|
expectedRoles: []string{"backend", "approver"},
|
|
},
|
|
{
|
|
comment: "Matches one group",
|
|
claims: map[string]any{
|
|
"groups": []string{"DemoCorp - SRE"},
|
|
},
|
|
expectedRoles: []string{"approver"},
|
|
},
|
|
{
|
|
comment: "Matches one group with multiple roles",
|
|
claims: map[string]any{
|
|
"groups": []string{"DemoCorp Infrastructure"},
|
|
},
|
|
expectedRoles: []string{"approver", "backend"},
|
|
},
|
|
{
|
|
comment: "No match only due to case-sensitivity",
|
|
claims: map[string]any{
|
|
"groups": []string{"Democorp - SRE"},
|
|
},
|
|
expectedRoles: []string(nil),
|
|
warnings: []string{
|
|
`trait "Democorp - SRE" matches value "DemoCorp - SRE" case-insensitively and would have yielded "approver" role`,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
func TestOIDCMapping(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for i, testCase := range oidcTestCases {
|
|
conn := types.OIDCConnectorV3{
|
|
Spec: types.OIDCConnectorSpecV3{
|
|
ClaimsToRoles: testCase.mappings,
|
|
},
|
|
}
|
|
for _, input := range testCase.inputs {
|
|
comment := fmt.Sprintf("OIDC Test case %v %q, input %q", i, testCase.comment, input.comment)
|
|
_, outRoles := TraitsToRoles(conn.GetTraitMappings(), oidcClaimsToTraits(input.claims))
|
|
require.Empty(t, cmp.Diff(outRoles, input.expectedRoles), comment)
|
|
}
|
|
|
|
samlConn := types.SAMLConnectorV2{
|
|
Spec: types.SAMLConnectorSpecV2{
|
|
AttributesToRoles: claimMappingsToAttributeMappings(testCase.mappings),
|
|
},
|
|
}
|
|
for _, input := range testCase.inputs {
|
|
comment := fmt.Sprintf("SAML Test case %v %v, input %#v", i, testCase.comment, input)
|
|
warnings, outRoles := TraitsToRoles(samlConn.GetTraitMappings(), SAMLAssertionsToTraits(claimsToAttributes(input.claims)))
|
|
require.Empty(t, cmp.Diff(outRoles, input.expectedRoles), comment)
|
|
require.Empty(t, cmp.Diff(warnings, input.warnings), comment)
|
|
}
|
|
}
|
|
}
|
|
func BenchmarkTraitToRoles(b *testing.B) {
|
|
for _, testCase := range oidcTestCases {
|
|
samlConn := types.SAMLConnectorV2{
|
|
Spec: types.SAMLConnectorSpecV2{
|
|
AttributesToRoles: claimMappingsToAttributeMappings(testCase.mappings),
|
|
},
|
|
}
|
|
mappings := samlConn.GetTraitMappings()
|
|
for _, input := range testCase.inputs {
|
|
testCaseInputName := fmt.Sprintf("%s %s", testCase.comment, input.comment)
|
|
traits := SAMLAssertionsToTraits(claimsToAttributes(input.claims))
|
|
|
|
b.Run(testCaseInputName, func(b *testing.B) {
|
|
for b.Loop() {
|
|
TraitsToRoles(mappings, traits)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// claimMappingsToAttributeMappings converts oidc claim mappings to
|
|
// attribute mappings, used in tests
|
|
func claimMappingsToAttributeMappings(in []types.ClaimMapping) []types.AttributeMapping {
|
|
var out []types.AttributeMapping
|
|
for _, m := range in {
|
|
out = append(out, types.AttributeMapping{
|
|
Name: m.Claim,
|
|
Value: m.Value,
|
|
Roles: slices.Clone(m.Roles),
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// oidcClaimsToTraits converts OIDC-style claims into teleport-specific trait format
|
|
func oidcClaimsToTraits(claims map[string]any) map[string][]string {
|
|
traits := make(map[string][]string)
|
|
|
|
for claimName, v := range claims {
|
|
|
|
switch claimValue := v.(type) {
|
|
case string:
|
|
traits[claimName] = []string{claimValue}
|
|
case []string:
|
|
traits[claimName] = claimValue
|
|
case []any:
|
|
for _, vv := range claimValue {
|
|
traits[claimName] = append(traits[claimName], vv.(string))
|
|
}
|
|
}
|
|
}
|
|
|
|
return traits
|
|
}
|
|
|
|
// claimsToAttributes maps map[string]any type to attributes for testing
|
|
func claimsToAttributes(claims map[string]any) saml2.AssertionInfo {
|
|
info := saml2.AssertionInfo{
|
|
Values: make(map[string]samltypes.Attribute),
|
|
}
|
|
for claim, values := range claims {
|
|
attr := samltypes.Attribute{
|
|
Name: claim,
|
|
}
|
|
switch val := values.(type) {
|
|
case string:
|
|
attr.Values = []samltypes.AttributeValue{{Value: val}}
|
|
case []string:
|
|
for _, v := range val {
|
|
attr.Values = append(attr.Values, samltypes.AttributeValue{Value: v})
|
|
}
|
|
default:
|
|
panic(fmt.Sprintf("unsupported type %T", val))
|
|
}
|
|
info.Values[claim] = attr
|
|
}
|
|
return info
|
|
}
|
|
|
|
func TestUsernameForCluster(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
username string
|
|
originCluster string
|
|
localClusterName string
|
|
expected string
|
|
}{
|
|
|
|
{
|
|
username: "alice",
|
|
originCluster: "leaf",
|
|
localClusterName: "root",
|
|
expected: "remote-alice-leaf",
|
|
},
|
|
{
|
|
username: "bob",
|
|
originCluster: "",
|
|
localClusterName: "root",
|
|
expected: "bob",
|
|
},
|
|
{
|
|
username: "carol",
|
|
originCluster: "leaf.cluster",
|
|
localClusterName: "root.cluster",
|
|
expected: "remote-carol-leaf.cluster",
|
|
},
|
|
{
|
|
username: "dave",
|
|
originCluster: "leaf-cluster",
|
|
localClusterName: "leaf-cluster",
|
|
expected: "dave",
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.username, func(t *testing.T) {
|
|
result := UsernameForCluster(
|
|
UsernameForClusterConfig{
|
|
User: test.username,
|
|
OriginClusterName: test.originCluster,
|
|
LocalClusterName: test.localClusterName,
|
|
},
|
|
)
|
|
require.Equal(t, test.expected, result)
|
|
})
|
|
}
|
|
}
|
|
|
|
// fakeUserGetter is a UserGetter backed by an in-memory map. It records lookup
|
|
// counts per username so tests can assert the dedupe behavior.
|
|
type fakeUserGetter struct {
|
|
users map[string]types.User
|
|
failFor map[string]error // username -> error returned instead of a lookup
|
|
|
|
calls map[string]int // username -> GetUser call count
|
|
}
|
|
|
|
func (f *fakeUserGetter) GetUser(_ context.Context, name string, _ bool) (types.User, error) {
|
|
if f.calls == nil {
|
|
f.calls = make(map[string]int)
|
|
}
|
|
f.calls[name]++
|
|
if err, ok := f.failFor[name]; ok {
|
|
return nil, err
|
|
}
|
|
user, ok := f.users[name]
|
|
if !ok {
|
|
return nil, trace.NotFound("user %q does not exist", name)
|
|
}
|
|
return user, nil
|
|
}
|
|
|
|
func newUserWithTraits(t *testing.T, name string, traits map[string][]string) types.User {
|
|
t.Helper()
|
|
user, err := types.NewUser(name)
|
|
require.NoError(t, err)
|
|
if traits != nil {
|
|
user.SetTraits(traits)
|
|
}
|
|
return user
|
|
}
|
|
|
|
func TestResolveUserDisplays(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("dedupes and issues one lookup per unique username", func(t *testing.T) {
|
|
t.Parallel()
|
|
getter := &fakeUserGetter{users: map[string]types.User{
|
|
"alice": newUserWithTraits(t, "alice", nil),
|
|
"bob": newUserWithTraits(t, "bob", nil),
|
|
}}
|
|
|
|
out, err := ResolveUserDisplays(context.Background(), getter, []string{"alice", "alice", "bob", "alice"})
|
|
require.NoError(t, err)
|
|
require.Len(t, out, 2)
|
|
require.Contains(t, out, "alice")
|
|
require.Contains(t, out, "bob")
|
|
require.Equal(t, 1, getter.calls["alice"])
|
|
require.Equal(t, 1, getter.calls["bob"])
|
|
})
|
|
|
|
t.Run("returns the display value for a found user", func(t *testing.T) {
|
|
t.Parallel()
|
|
alice := newUserWithTraits(t, "alice", map[string][]string{
|
|
"displayName": {"Alice Liddell"},
|
|
"email": {"alice@example.com"},
|
|
})
|
|
|
|
want := alice.GetDisplay()
|
|
// Sanity-check that the chosen traits actually produce a display, so the
|
|
// assertion below is meaningful.
|
|
require.NotEqual(t, types.UserDisplay{}, want)
|
|
|
|
getter := &fakeUserGetter{users: map[string]types.User{"alice": alice}}
|
|
out, err := ResolveUserDisplays(context.Background(), getter, []string{"alice"})
|
|
require.NoError(t, err)
|
|
require.Equal(t, want, out["alice"])
|
|
})
|
|
|
|
t.Run("found user with no display is present with a zero value", func(t *testing.T) {
|
|
t.Parallel()
|
|
getter := &fakeUserGetter{users: map[string]types.User{
|
|
"plain": newUserWithTraits(t, "plain", nil),
|
|
}}
|
|
|
|
out, err := ResolveUserDisplays(context.Background(), getter, []string{"plain"})
|
|
require.NoError(t, err)
|
|
|
|
// present with the zero value, not missing from the map
|
|
require.Contains(t, out, "plain")
|
|
require.Equal(t, types.UserDisplay{}, out["plain"])
|
|
})
|
|
|
|
t.Run("missing users are absent and do not fail resolution", func(t *testing.T) {
|
|
t.Parallel()
|
|
getter := &fakeUserGetter{users: map[string]types.User{
|
|
"alice": newUserWithTraits(t, "alice", nil),
|
|
"bob": newUserWithTraits(t, "bob", nil),
|
|
}}
|
|
|
|
out, err := ResolveUserDisplays(context.Background(), getter, []string{"alice", "ghost", "bob"})
|
|
require.NoError(t, err)
|
|
require.Len(t, out, 2)
|
|
require.Contains(t, out, "alice")
|
|
require.Contains(t, out, "bob")
|
|
require.NotContains(t, out, "ghost")
|
|
})
|
|
|
|
t.Run("aborts on non-NotFound errors without a partial map", func(t *testing.T) {
|
|
t.Parallel()
|
|
for _, errorCase := range []struct {
|
|
name string
|
|
err error
|
|
}{
|
|
{"transient backend error", errors.New("backend timeout")},
|
|
// A canceled/expired context surfaces through the getter as a
|
|
// non-NotFound error and must abort like any other.
|
|
{"context cancellation", context.Canceled},
|
|
} {
|
|
t.Run(errorCase.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
getter := &fakeUserGetter{
|
|
users: map[string]types.User{"alice": newUserWithTraits(t, "alice", nil)},
|
|
failFor: map[string]error{"bob": errorCase.err},
|
|
}
|
|
|
|
out, err := ResolveUserDisplays(context.Background(), getter, []string{"alice", "bob", "carol"})
|
|
require.Error(t, err)
|
|
require.ErrorIs(t, err, errorCase.err) // original error preserved
|
|
require.Contains(t, err.Error(), "bob") // names the error user
|
|
require.Nil(t, out) // no partial map handed back
|
|
})
|
|
}
|
|
})
|
|
}
|