mirror of
https://github.com/gravitational/teleport.git
synced 2026-10-12 02:15:01 +00:00
* Allow GitHub login for users whose roles come exclusively from access lists * Update component test expectation * chore: update design system to v0.2.0 (#68515) * Add suggested nit changes --------- Co-authored-by: Nibras Ohin <[email protected]>
952 lines
28 KiB
Go
952 lines
28 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 auth_test
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/go-cmp/cmp"
|
|
"github.com/google/uuid"
|
|
"github.com/gravitational/trace"
|
|
"github.com/jonboulle/clockwork"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/gravitational/teleport/api/constants"
|
|
"github.com/gravitational/teleport/api/defaults"
|
|
"github.com/gravitational/teleport/api/types"
|
|
"github.com/gravitational/teleport/api/types/accesslist"
|
|
apievents "github.com/gravitational/teleport/api/types/events"
|
|
"github.com/gravitational/teleport/api/types/header"
|
|
"github.com/gravitational/teleport/entitlements"
|
|
"github.com/gravitational/teleport/lib/auth"
|
|
"github.com/gravitational/teleport/lib/auth/authclient"
|
|
"github.com/gravitational/teleport/lib/auth/authtest"
|
|
authority "github.com/gravitational/teleport/lib/auth/testauthority"
|
|
"github.com/gravitational/teleport/lib/authz"
|
|
"github.com/gravitational/teleport/lib/backend"
|
|
"github.com/gravitational/teleport/lib/backend/memory"
|
|
"github.com/gravitational/teleport/lib/events"
|
|
"github.com/gravitational/teleport/lib/events/eventstest"
|
|
"github.com/gravitational/teleport/lib/loginrule"
|
|
"github.com/gravitational/teleport/lib/modules"
|
|
"github.com/gravitational/teleport/lib/modules/modulestest"
|
|
"github.com/gravitational/teleport/lib/services"
|
|
"github.com/gravitational/teleport/lib/utils"
|
|
"github.com/gravitational/teleport/lib/utils/log/logtest"
|
|
)
|
|
|
|
type githubContext struct {
|
|
a *auth.Server
|
|
mockEmitter *eventstest.MockRecorderEmitter
|
|
b backend.Backend
|
|
c *clockwork.FakeClock
|
|
}
|
|
|
|
// TODO(nixpig): Refactor to avoid using global modules.
|
|
// See: https://github.com/gravitational/teleport/pull/63829#discussion_r3646729631
|
|
func setupGithubContext(t *testing.T) *githubContext {
|
|
var tt githubContext
|
|
t.Cleanup(func() { tt.Close() })
|
|
|
|
tt.c = clockwork.NewFakeClockAt(time.Now())
|
|
|
|
var err error
|
|
tt.b, err = memory.New(memory.Config{
|
|
Context: t.Context(),
|
|
Clock: tt.c,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
clusterName, err := services.NewClusterNameWithRandomID(types.ClusterNameSpecV2{
|
|
ClusterName: "me.localhost",
|
|
})
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() {
|
|
require.NoError(t, tt.b.Close())
|
|
})
|
|
|
|
keygen, err := authority.NewKeygen(modules.BuildOSS, tt.c.Now)
|
|
require.NoError(t, err)
|
|
|
|
authConfig := &auth.InitConfig{
|
|
ClusterName: clusterName,
|
|
Backend: tt.b,
|
|
VersionStorage: authtest.NewFakeTeleportVersion(),
|
|
Authority: keygen,
|
|
SkipPeriodicOperations: true,
|
|
HostUUID: uuid.NewString(),
|
|
Modules: modulestest.OSSModules(),
|
|
}
|
|
tt.a, err = auth.NewServer(authConfig)
|
|
require.NoError(t, err)
|
|
|
|
tt.mockEmitter = &eventstest.MockRecorderEmitter{}
|
|
tt.a.SetEmitter(tt.mockEmitter)
|
|
|
|
return &tt
|
|
}
|
|
|
|
func (tt *githubContext) Close() error {
|
|
return trace.NewAggregate(
|
|
tt.a.Close(),
|
|
tt.b.Close())
|
|
}
|
|
|
|
func TestPopulateClaims(t *testing.T) {
|
|
t.Parallel()
|
|
client := &testGithubAPIClient{}
|
|
user, err := client.getUser()
|
|
require.NoError(t, err)
|
|
teams, err := client.getTeams()
|
|
require.NoError(t, err)
|
|
|
|
claims, err := auth.PopulateGithubClaims(user, teams)
|
|
require.NoError(t, err)
|
|
require.Empty(t, cmp.Diff(claims, &types.GithubClaims{
|
|
Username: "octocat",
|
|
UserID: "1234567",
|
|
OrganizationToTeams: map[string][]string{
|
|
"org1": {"team1", "team2"},
|
|
"org2": {"team1"},
|
|
},
|
|
Teams: []string{"team1", "team2", "team1"},
|
|
}))
|
|
}
|
|
|
|
func TestCreateGithubUser(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := t.Context()
|
|
tt := setupGithubContext(t)
|
|
|
|
// Dry-run creation of Github user.
|
|
user, err := tt.a.CreateGithubUser(ctx, &auth.CreateUserParams{
|
|
ConnectorName: "github",
|
|
Username: "[email protected]",
|
|
Roles: []string{"admin"},
|
|
SessionTTL: 1 * time.Minute,
|
|
}, true)
|
|
require.NoError(t, err)
|
|
require.Equal(t, "[email protected]", user.GetName())
|
|
|
|
// Dry-run must not create a user.
|
|
_, err = tt.a.GetUser(ctx, "[email protected]", false)
|
|
require.Error(t, err)
|
|
|
|
// Create GitHub user with 1 minute expiry.
|
|
_, err = tt.a.CreateGithubUser(ctx, &auth.CreateUserParams{
|
|
ConnectorName: "github",
|
|
Username: "foo",
|
|
Roles: []string{"admin"},
|
|
SessionTTL: 1 * time.Minute,
|
|
}, false)
|
|
require.NoError(t, err)
|
|
|
|
// Within that 1 minute period the user should still exist.
|
|
_, err = tt.a.GetUser(ctx, "foo", false)
|
|
require.NoError(t, err)
|
|
|
|
// Advance time 2 minutes, the user should be gone.
|
|
tt.c.Advance(2 * time.Minute)
|
|
_, err = tt.a.GetUser(ctx, "foo", false)
|
|
require.Error(t, err)
|
|
}
|
|
|
|
type testGithubAPIClient struct{}
|
|
|
|
func (c *testGithubAPIClient) getUser() (*auth.GithubUserResponse, error) {
|
|
return &auth.GithubUserResponse{Login: "octocat", ID: 1234567}, nil
|
|
}
|
|
|
|
func (c *testGithubAPIClient) getTeams() ([]auth.GithubTeamResponse, error) {
|
|
return []auth.GithubTeamResponse{
|
|
{
|
|
Name: "team1",
|
|
Slug: "team1",
|
|
Org: auth.GithubOrgResponse{Login: "org1"},
|
|
},
|
|
{
|
|
Name: "team2",
|
|
Slug: "team2",
|
|
Org: auth.GithubOrgResponse{Login: "org1"},
|
|
},
|
|
{
|
|
Name: "team1",
|
|
Slug: "team1",
|
|
Org: auth.GithubOrgResponse{Login: "org2"},
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
func TestValidateGithubAuthCallbackEventsEmitted(t *testing.T) {
|
|
t.Parallel()
|
|
clientAddr := &net.TCPAddr{IP: net.IPv4(10, 255, 0, 0)}
|
|
ctx := authz.ContextWithClientSrcAddr(context.Background(), clientAddr)
|
|
tt := setupGithubContext(t)
|
|
logger := logtest.NewLogger()
|
|
|
|
resp := &authclient.GithubAuthResponse{
|
|
Username: "test-name",
|
|
}
|
|
|
|
claims := &types.GithubClaims{
|
|
OrganizationToTeams: map[string][]string{
|
|
"test": {},
|
|
},
|
|
}
|
|
|
|
ssoDiagInfoCalls := 0
|
|
createSSODiagnosticInfoStub := func(ctx context.Context, authKind string, authRequestID string, entry types.SSODiagnosticInfo) error {
|
|
ssoDiagInfoCalls++
|
|
return nil
|
|
}
|
|
|
|
ssoDiagContextFixture := func(testFlow bool) *auth.SSODiagContext {
|
|
diagCtx := auth.NewSSODiagContext(types.KindGithub, auth.SSODiagServiceFunc(createSSODiagnosticInfoStub))
|
|
diagCtx.RequestID = uuid.New().String()
|
|
diagCtx.Info.TestFlow = testFlow
|
|
return diagCtx
|
|
}
|
|
m := &mockedGithubManager{}
|
|
|
|
// Test success event, non-test-flow.
|
|
diagCtx := ssoDiagContextFixture(false /* testFlow */)
|
|
m.mockValidateGithubAuthCallback = func(ctx context.Context, diagCtx *auth.SSODiagContext, q url.Values) (*authclient.GithubAuthResponse, error) {
|
|
diagCtx.Info.GithubClaims = claims
|
|
diagCtx.Info.AppliedLoginRules = []string{"login-rule"}
|
|
return resp, nil
|
|
}
|
|
_, _ = auth.ValidateGithubAuthCallbackHelper(ctx, m, diagCtx, nil, tt.a.GetEmitter(), logger)
|
|
require.Equal(t, events.UserLoginEvent, tt.mockEmitter.LastEvent().GetType())
|
|
require.Equal(t, events.UserSSOLoginCode, tt.mockEmitter.LastEvent().GetCode())
|
|
loginEvt := tt.mockEmitter.LastEvent().(*apievents.UserLogin)
|
|
require.Equal(t, []string{"login-rule"}, loginEvt.AppliedLoginRules)
|
|
require.Equal(t, clientAddr.String(), loginEvt.ConnectionMetadata.RemoteAddr)
|
|
require.Equal(t, 0, ssoDiagInfoCalls)
|
|
tt.mockEmitter.Reset()
|
|
|
|
// Test failure event, non-test-flow.
|
|
diagCtx = ssoDiagContextFixture(false /* testFlow */)
|
|
m.mockValidateGithubAuthCallback = func(ctx context.Context, diagCtx *auth.SSODiagContext, q url.Values) (*authclient.GithubAuthResponse, error) {
|
|
diagCtx.Info.GithubClaims = claims
|
|
return resp, trace.BadParameter("")
|
|
}
|
|
_, _ = auth.ValidateGithubAuthCallbackHelper(ctx, m, diagCtx, nil, tt.a.GetEmitter(), logger)
|
|
require.Equal(t, events.UserLoginEvent, tt.mockEmitter.LastEvent().GetType())
|
|
require.Equal(t, events.UserSSOLoginFailureCode, tt.mockEmitter.LastEvent().GetCode())
|
|
loginEvt = tt.mockEmitter.LastEvent().(*apievents.UserLogin)
|
|
require.Equal(t, clientAddr.String(), loginEvt.ConnectionMetadata.RemoteAddr)
|
|
require.Equal(t, 0, ssoDiagInfoCalls)
|
|
|
|
// Test success event, test-flow.
|
|
diagCtx = ssoDiagContextFixture(true /* testFlow */)
|
|
m.mockValidateGithubAuthCallback = func(ctx context.Context, diagCtx *auth.SSODiagContext, q url.Values) (*authclient.GithubAuthResponse, error) {
|
|
diagCtx.Info.GithubClaims = claims
|
|
return resp, nil
|
|
}
|
|
_, _ = auth.ValidateGithubAuthCallbackHelper(ctx, m, diagCtx, nil, tt.a.GetEmitter(), logger)
|
|
require.Equal(t, events.UserLoginEvent, tt.mockEmitter.LastEvent().GetType())
|
|
require.Equal(t, events.UserSSOTestFlowLoginCode, tt.mockEmitter.LastEvent().GetCode())
|
|
loginEvt = tt.mockEmitter.LastEvent().(*apievents.UserLogin)
|
|
require.Equal(t, clientAddr.String(), loginEvt.ConnectionMetadata.RemoteAddr)
|
|
require.Equal(t, 1, ssoDiagInfoCalls)
|
|
tt.mockEmitter.Reset()
|
|
|
|
// Test failure event, test-flow.
|
|
diagCtx = ssoDiagContextFixture(true /* testFlow */)
|
|
m.mockValidateGithubAuthCallback = func(ctx context.Context, diagCtx *auth.SSODiagContext, q url.Values) (*authclient.GithubAuthResponse, error) {
|
|
diagCtx.Info.GithubClaims = claims
|
|
return resp, trace.BadParameter("")
|
|
}
|
|
_, _ = auth.ValidateGithubAuthCallbackHelper(ctx, m, diagCtx, nil, tt.a.GetEmitter(), logger)
|
|
require.Equal(t, events.UserLoginEvent, tt.mockEmitter.LastEvent().GetType())
|
|
require.Equal(t, events.UserSSOTestFlowLoginFailureCode, tt.mockEmitter.LastEvent().GetCode())
|
|
loginEvt = tt.mockEmitter.LastEvent().(*apievents.UserLogin)
|
|
require.Equal(t, clientAddr.String(), loginEvt.ConnectionMetadata.RemoteAddr)
|
|
require.Equal(t, 2, ssoDiagInfoCalls)
|
|
}
|
|
|
|
type mockedGithubManager struct {
|
|
mockValidateGithubAuthCallback func(ctx context.Context, diagCtx *auth.SSODiagContext, q url.Values) (*authclient.GithubAuthResponse, error)
|
|
}
|
|
|
|
func (m *mockedGithubManager) ValidateGithubAuthRedirect(ctx context.Context, diagCtx *auth.SSODiagContext, q url.Values) (*authclient.GithubAuthResponse, error) {
|
|
if m.mockValidateGithubAuthCallback != nil {
|
|
return m.mockValidateGithubAuthCallback(ctx, diagCtx, q)
|
|
}
|
|
|
|
return nil, trace.NotImplemented("mockValidateGithubAuthCallback not implemented")
|
|
}
|
|
|
|
func TestCalculateGithubUserNoTeams(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := context.Background()
|
|
a := &auth.Server{}
|
|
connector, err := types.NewGithubConnector("github", types.GithubConnectorSpecV3{
|
|
TeamsToRoles: []types.TeamRolesMapping{
|
|
{
|
|
Organization: "org1",
|
|
Team: "teamx",
|
|
Roles: []string{"role"},
|
|
},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
diagCtx := &auth.SSODiagContext{}
|
|
|
|
params, err := a.CalculateGithubUser(ctx, diagCtx, connector, &types.GithubClaims{
|
|
Username: "octocat",
|
|
OrganizationToTeams: map[string][]string{
|
|
"org1": {"team1", "team2"},
|
|
"org2": {"team1"},
|
|
},
|
|
Teams: []string{"team1", "team2", "team1"},
|
|
}, &types.GithubAuthRequest{})
|
|
require.NoError(t, err)
|
|
require.Empty(t, params.Roles)
|
|
}
|
|
|
|
// Test that calculateGithubUser calls the login rule evaluator, evaluated
|
|
// traits end up in the user params, and traits are evaluated exactly once.
|
|
func TestCalculateGithubUserWithLoginRules(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := context.Background()
|
|
|
|
// Create a test role so that FetchRoles can succeed.
|
|
roles := map[string]types.Role{
|
|
"access": &types.RoleV6{
|
|
Metadata: types.Metadata{
|
|
Name: "access",
|
|
},
|
|
Spec: types.RoleSpecV6{
|
|
Allow: types.RoleConditions{
|
|
Logins: []string{"{{internal.logins}}"},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
a := &auth.Server{
|
|
Cache: &mockRoleCache{
|
|
roles: roles,
|
|
},
|
|
}
|
|
|
|
// Insert a mock login rule evaluator with static outputs, the real login
|
|
// rule evaluator is in the enterprise codebase.
|
|
evaluatedTraits := map[string][]string{
|
|
"logins": {"octocat", "octodog"},
|
|
"teams": {"access", "team1", "team3"},
|
|
constants.TraitKubeGroups: {"kubers"},
|
|
constants.TraitKubeUsers: {"k8"},
|
|
}
|
|
mockEvaluator := &mockLoginRuleEvaluator{
|
|
outputTraits: evaluatedTraits,
|
|
ruleNames: []string{"mock"},
|
|
}
|
|
a.SetLoginRuleEvaluator(mockEvaluator)
|
|
|
|
// Create a basic connector to map to the test role.
|
|
connector, err := types.NewGithubConnector("github", types.GithubConnectorSpecV3{
|
|
TeamsToRoles: []types.TeamRolesMapping{
|
|
{
|
|
Organization: "org1",
|
|
Team: "team1",
|
|
Roles: []string{"access"},
|
|
},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
diagCtx := &auth.SSODiagContext{}
|
|
|
|
userParams, err := a.CalculateGithubUser(ctx, diagCtx, connector, &types.GithubClaims{
|
|
Username: "octocat",
|
|
OrganizationToTeams: map[string][]string{
|
|
"org1": {"team1"},
|
|
},
|
|
Teams: []string{"team1"},
|
|
}, &types.GithubAuthRequest{})
|
|
require.NoError(t, err)
|
|
|
|
require.Equal(t, &auth.CreateUserParams{
|
|
ConnectorName: "github",
|
|
Username: "octocat",
|
|
KubeGroups: evaluatedTraits[constants.TraitKubeGroups],
|
|
KubeUsers: evaluatedTraits[constants.TraitKubeUsers],
|
|
Roles: []string{"access"},
|
|
Traits: evaluatedTraits,
|
|
SessionTTL: defaults.MaxCertDuration,
|
|
}, userParams, "user params does not match expected")
|
|
require.Equal(t, 1, mockEvaluator.evaluatedCount, "login rules were not evaluated exactly once")
|
|
require.Equal(t, mockEvaluator.ruleNames, diagCtx.Info.AppliedLoginRules)
|
|
}
|
|
|
|
type mockRoleCache struct {
|
|
roles map[string]types.Role
|
|
authclient.Cache
|
|
}
|
|
|
|
func (m *mockRoleCache) GetRole(_ context.Context, name string) (types.Role, error) {
|
|
return m.roles[name], nil
|
|
}
|
|
|
|
type mockLoginRuleEvaluator struct {
|
|
outputTraits map[string][]string
|
|
evaluatedCount int
|
|
ruleNames []string
|
|
}
|
|
|
|
func (m *mockLoginRuleEvaluator) Evaluate(context.Context, *loginrule.EvaluationInput) (*loginrule.EvaluationOutput, error) {
|
|
m.evaluatedCount++
|
|
return &loginrule.EvaluationOutput{
|
|
Traits: m.outputTraits,
|
|
AppliedRules: m.ruleNames,
|
|
}, nil
|
|
}
|
|
|
|
type mockHTTPRequester struct {
|
|
succeed bool
|
|
statusCode int
|
|
}
|
|
|
|
func (m mockHTTPRequester) Do(req *http.Request) (*http.Response, error) {
|
|
if !m.succeed {
|
|
return nil, &url.Error{
|
|
URL: req.URL.String(),
|
|
Err: &net.DNSError{
|
|
IsTimeout: true,
|
|
},
|
|
}
|
|
}
|
|
|
|
resp := new(http.Response)
|
|
resp.Body = io.NopCloser(bytes.NewReader([]byte{}))
|
|
resp.StatusCode = m.statusCode
|
|
|
|
return resp, nil
|
|
}
|
|
|
|
func TestCheckGithubOrgSSOSupport(t *testing.T) {
|
|
noSSOOrg, err := types.NewGithubConnector("github-no-sso", types.GithubConnectorSpecV3{
|
|
TeamsToRoles: []types.TeamRolesMapping{
|
|
{
|
|
Organization: "no-sso-org",
|
|
Team: "team",
|
|
Roles: []string{"role"},
|
|
},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
ssoOrg, err := types.NewGithubConnector("github-sso", types.GithubConnectorSpecV3{
|
|
TeamsToRoles: []types.TeamRolesMapping{
|
|
{
|
|
Organization: "sso-org",
|
|
Team: "team",
|
|
Roles: []string{"role"},
|
|
},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
tests := []struct {
|
|
testName string
|
|
connector types.GithubConnector
|
|
buildType string
|
|
requestShouldSucceed bool
|
|
httpStatusCode int
|
|
reuseCache bool
|
|
errFunc func(error) bool
|
|
}{
|
|
{
|
|
testName: "OSS HTTP connection failure",
|
|
connector: ssoOrg,
|
|
buildType: modules.BuildOSS,
|
|
requestShouldSucceed: false,
|
|
reuseCache: false,
|
|
errFunc: trace.IsConnectionProblem,
|
|
},
|
|
{
|
|
testName: "Enterprise skips HTTP check",
|
|
connector: ssoOrg,
|
|
buildType: modules.BuildEnterprise,
|
|
requestShouldSucceed: false,
|
|
reuseCache: false,
|
|
errFunc: nil,
|
|
},
|
|
{
|
|
testName: "OSS has SSO",
|
|
connector: ssoOrg,
|
|
buildType: modules.BuildOSS,
|
|
requestShouldSucceed: true,
|
|
httpStatusCode: http.StatusOK,
|
|
reuseCache: false,
|
|
errFunc: trace.IsAccessDenied,
|
|
},
|
|
{
|
|
testName: "OSS has SSO with cache",
|
|
connector: ssoOrg,
|
|
buildType: modules.BuildOSS,
|
|
requestShouldSucceed: false,
|
|
reuseCache: true,
|
|
errFunc: trace.IsAccessDenied,
|
|
},
|
|
{
|
|
testName: "OSS doesn't have SSO",
|
|
connector: noSSOOrg,
|
|
buildType: modules.BuildOSS,
|
|
requestShouldSucceed: true,
|
|
httpStatusCode: 404,
|
|
reuseCache: true,
|
|
errFunc: nil,
|
|
},
|
|
{
|
|
testName: "OSS doesn't have SSO with cache",
|
|
connector: noSSOOrg,
|
|
buildType: modules.BuildOSS,
|
|
requestShouldSucceed: false,
|
|
reuseCache: true,
|
|
errFunc: nil,
|
|
},
|
|
}
|
|
|
|
var orgCache *utils.FnCache
|
|
ctx := t.Context()
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.testName, func(t *testing.T) {
|
|
client := mockHTTPRequester{
|
|
succeed: tt.requestShouldSucceed,
|
|
statusCode: tt.httpStatusCode,
|
|
}
|
|
|
|
if !tt.reuseCache {
|
|
orgCache, err = utils.NewFnCache(utils.FnCacheConfig{
|
|
TTL: time.Minute,
|
|
})
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
err := auth.CheckGithubOrgSSOSupport(ctx, tt.connector, nil, tt.buildType, orgCache, client)
|
|
if tt.errFunc == nil {
|
|
require.NoError(t, err)
|
|
} else {
|
|
require.Error(t, err)
|
|
require.True(t, tt.errFunc(err))
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGithubURLFormat(t *testing.T) {
|
|
t.Parallel()
|
|
tts := []struct {
|
|
host string
|
|
path string
|
|
expect string
|
|
}{
|
|
{
|
|
host: "example.com",
|
|
path: "foo/bar",
|
|
expect: "https://example.com/foo/bar",
|
|
},
|
|
{
|
|
host: "example.com",
|
|
path: "/foo/bar?spam=eggs",
|
|
expect: "https://example.com/foo/bar?spam=eggs",
|
|
},
|
|
{
|
|
host: "example.com",
|
|
path: "/foo/bar",
|
|
expect: "https://example.com/foo/bar",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tts {
|
|
require.Equal(t, tt.expect, auth.FormatGithubURL(tt.host, tt.path))
|
|
}
|
|
}
|
|
|
|
func TestBuildAPIEndpoint(t *testing.T) {
|
|
t.Parallel()
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
expected string
|
|
}{
|
|
{
|
|
name: "no path",
|
|
input: "https://github.com",
|
|
expected: "github.com",
|
|
},
|
|
{
|
|
name: "with path",
|
|
input: "https://mykewlapiendpoint/apage",
|
|
expected: "mykewlapiendpoint/apage",
|
|
},
|
|
{
|
|
name: "with path and double slashes",
|
|
input: "https://mykewlapiendpoint//apage//",
|
|
expected: "mykewlapiendpoint/apage/",
|
|
},
|
|
{
|
|
name: "with path and query",
|
|
input: "https://mykewlapiendpoint/apage?legit=nope",
|
|
expected: "mykewlapiendpoint/apage",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got, err := auth.BuildAPIEndpoint(tt.input)
|
|
require.NoError(t, err)
|
|
require.Equal(t, tt.expected, got)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGithubUserRoleMappingCombinations(t *testing.T) {
|
|
modulestest.SetTestModules(t, modulestest.Modules{
|
|
TestBuildType: modules.BuildEnterprise,
|
|
TestFeatures: modules.Features{
|
|
Entitlements: map[entitlements.EntitlementKind]modules.EntitlementInfo{
|
|
entitlements.Identity: {Enabled: true},
|
|
},
|
|
},
|
|
})
|
|
|
|
ctx := t.Context()
|
|
tt := setupGithubContext(t)
|
|
|
|
accessRole, err := types.NewRole("access", types.RoleSpecV6{})
|
|
require.NoError(t, err)
|
|
|
|
_, err = tt.a.CreateRole(ctx, accessRole)
|
|
require.NoError(t, err)
|
|
|
|
connector, err := types.NewGithubConnector("github", types.GithubConnectorSpecV3{
|
|
TeamsToRoles: []types.TeamRolesMapping{{
|
|
Organization: "org1",
|
|
Team: "teamx",
|
|
Roles: []string{"access"},
|
|
}},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
tests := []struct {
|
|
testName string
|
|
claims *types.GithubClaims
|
|
setup func(t *testing.T, username string)
|
|
assertion func(t *testing.T, userState services.UserState, err error)
|
|
}{
|
|
{
|
|
testName: "SSO role mapping and access list role mapping",
|
|
claims: &types.GithubClaims{
|
|
Username: "testuser1",
|
|
OrganizationToTeams: map[string][]string{"org1": {"teamx"}},
|
|
Teams: []string{"teamx"},
|
|
},
|
|
setup: func(t *testing.T, username string) {
|
|
acl, err := accesslist.NewAccessList(
|
|
header.Metadata{Name: "test-acl-user1"},
|
|
accesslist.Spec{
|
|
Title: "title",
|
|
Owners: []accesslist.Owner{{Name: "owner"}},
|
|
Audit: accesslist.Audit{NextAuditDate: time.Now().Add(24 * time.Hour)},
|
|
MembershipRequires: accesslist.Requires{},
|
|
OwnershipRequires: accesslist.Requires{},
|
|
Grants: accesslist.Grants{Roles: []string{"access"}},
|
|
},
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
member, err := accesslist.NewAccessListMember(
|
|
header.Metadata{Name: username},
|
|
accesslist.AccessListMemberSpec{
|
|
AccessList: acl.GetName(),
|
|
Name: username,
|
|
Joined: time.Now(),
|
|
Expires: time.Now().Add(24 * time.Hour),
|
|
Reason: "test",
|
|
AddedBy: "admin",
|
|
},
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
_, _, err = tt.a.UpsertAccessListWithMembers(ctx, acl, []*accesslist.AccessListMember{member})
|
|
require.NoError(t, err)
|
|
|
|
t.Cleanup(func() {
|
|
tt.a.DeleteAccessList(ctx, acl.GetName())
|
|
})
|
|
},
|
|
assertion: func(t *testing.T, userState services.UserState, err error) {
|
|
require.NoError(t, err)
|
|
require.Contains(t, userState.GetRoles(), "access")
|
|
},
|
|
},
|
|
{
|
|
testName: "SSO role mapping and no access list role mapping",
|
|
claims: &types.GithubClaims{
|
|
Username: "testuser2",
|
|
OrganizationToTeams: map[string][]string{"org1": {"teamx"}},
|
|
Teams: []string{"teamx"},
|
|
},
|
|
assertion: func(t *testing.T, userState services.UserState, err error) {
|
|
require.NoError(t, err)
|
|
require.Contains(t, userState.GetRoles(), "access")
|
|
},
|
|
},
|
|
{
|
|
testName: "no SSO role mapping and access list role mapping",
|
|
claims: &types.GithubClaims{
|
|
Username: "testuser3",
|
|
OrganizationToTeams: map[string][]string{"org1": {"other-team"}},
|
|
Teams: []string{"other-team"},
|
|
},
|
|
setup: func(t *testing.T, username string) {
|
|
acl, err := accesslist.NewAccessList(
|
|
header.Metadata{Name: "test-acl-user3"},
|
|
accesslist.Spec{
|
|
Title: "title",
|
|
Owners: []accesslist.Owner{{Name: "owner"}},
|
|
Audit: accesslist.Audit{
|
|
NextAuditDate: time.Now().Add(365 * 24 * time.Hour),
|
|
},
|
|
MembershipRequires: accesslist.Requires{},
|
|
OwnershipRequires: accesslist.Requires{},
|
|
Grants: accesslist.Grants{Roles: []string{"access"}},
|
|
},
|
|
)
|
|
require.NoError(t, err)
|
|
member, err := accesslist.NewAccessListMember(
|
|
header.Metadata{Name: username},
|
|
accesslist.AccessListMemberSpec{
|
|
AccessList: acl.GetName(),
|
|
Name: username,
|
|
Joined: time.Now(),
|
|
Expires: time.Now().Add(24 * time.Hour),
|
|
Reason: "test",
|
|
AddedBy: "admin",
|
|
},
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
_, _, err = tt.a.UpsertAccessListWithMembers(ctx, acl, []*accesslist.AccessListMember{member})
|
|
require.NoError(t, err)
|
|
|
|
t.Cleanup(func() {
|
|
tt.a.DeleteAccessList(ctx, acl.GetName())
|
|
})
|
|
},
|
|
assertion: func(t *testing.T, userState services.UserState, err error) {
|
|
require.NoError(t, err)
|
|
require.Contains(t, userState.GetRoles(), "access")
|
|
},
|
|
},
|
|
{
|
|
testName: "no SSO role mapping and no access list role mapping",
|
|
claims: &types.GithubClaims{
|
|
Username: "testuser4",
|
|
OrganizationToTeams: map[string][]string{"org1": {"other-team"}},
|
|
Teams: []string{"other-team"},
|
|
},
|
|
assertion: func(t *testing.T, userState services.UserState, err error) {
|
|
require.NoError(t, err)
|
|
require.Empty(t, userState.GetRoles())
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.testName, func(t *testing.T) {
|
|
diagCtx := &auth.SSODiagContext{}
|
|
|
|
params, err := tt.a.CalculateGithubUser(
|
|
ctx,
|
|
diagCtx,
|
|
connector,
|
|
test.claims,
|
|
&types.GithubAuthRequest{},
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
user, err := tt.a.CreateGithubUser(ctx, params, false)
|
|
require.NoError(t, err)
|
|
|
|
if test.setup != nil {
|
|
test.setup(t, user.GetName())
|
|
}
|
|
|
|
require.NoError(t, tt.a.CallLoginHooks(ctx, user))
|
|
|
|
userState, err := tt.a.GetUserOrLoginState(ctx, user.GetName())
|
|
|
|
test.assertion(t, userState, err)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGithubCallbackRoleValidation(t *testing.T) {
|
|
modulestest.SetTestModules(t, modulestest.Modules{
|
|
TestBuildType: modules.BuildEnterprise,
|
|
TestFeatures: modules.Features{
|
|
Entitlements: map[entitlements.EntitlementKind]modules.EntitlementInfo{
|
|
entitlements.Identity: {Enabled: true},
|
|
},
|
|
},
|
|
})
|
|
|
|
srv := newTestTLSServer(t)
|
|
ctx := t.Context()
|
|
|
|
connector, err := types.NewGithubConnector("github", types.GithubConnectorSpecV3{
|
|
ClientID: "test-client-id",
|
|
ClientSecret: "test-client-secret",
|
|
RedirectURL: "https://example.com/v1/webapi/github/callback",
|
|
Display: "Sign in with GitHub",
|
|
TeamsToRoles: []types.TeamRolesMapping{
|
|
{
|
|
Organization: "org1",
|
|
Team: "teamx",
|
|
Roles: []string{"access"},
|
|
},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
a := srv.Auth()
|
|
a.GithubUserAndTeamsOverride = func() (*auth.GithubUserResponse, []auth.GithubTeamResponse, error) {
|
|
return &auth.GithubUserResponse{
|
|
Login: "testuser1",
|
|
}, []auth.GithubTeamResponse{{
|
|
Name: "other-team",
|
|
Slug: "other-team",
|
|
Org: auth.GithubOrgResponse{Login: "org1"},
|
|
}}, nil
|
|
}
|
|
|
|
_, err = auth.UpsertGithubConnector(ctx, a, connector)
|
|
require.NoError(t, err)
|
|
|
|
_, err = authtest.CreateRole(ctx, a, "access", types.RoleSpecV6{
|
|
Options: types.RoleOptions{
|
|
// MaxSessionTTL less than default MinCertDuration to ensure session
|
|
// duration in constrained.
|
|
MaxSessionTTL: types.Duration(defaults.MinCertDuration / 2),
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
proxyClient, err := srv.NewClient(authtest.TestBuiltin(types.RoleProxy))
|
|
require.NoError(t, err)
|
|
|
|
tests := []struct {
|
|
testName string
|
|
createWebSession bool
|
|
setup func(t *testing.T)
|
|
assertion func(t *testing.T, resp *authclient.GithubAuthResponse, userState services.UserState, err error)
|
|
}{
|
|
{
|
|
testName: "user with no roles",
|
|
assertion: func(t *testing.T, resp *authclient.GithubAuthResponse, userState services.UserState, err error) {
|
|
require.Nil(t, resp)
|
|
require.Empty(t, userState.GetRoles())
|
|
require.ErrorIs(t, err, auth.ErrGithubNoRoles)
|
|
},
|
|
},
|
|
{
|
|
testName: "user with roles from ACL",
|
|
createWebSession: true,
|
|
setup: func(t *testing.T) {
|
|
acl, err := accesslist.NewAccessList(
|
|
header.Metadata{Name: "test-acl-user1"},
|
|
accesslist.Spec{
|
|
Title: "title",
|
|
Owners: []accesslist.Owner{{Name: "owner"}},
|
|
Audit: accesslist.Audit{NextAuditDate: time.Now().Add(24 * time.Hour)},
|
|
MembershipRequires: accesslist.Requires{},
|
|
OwnershipRequires: accesslist.Requires{},
|
|
Grants: accesslist.Grants{Roles: []string{"access"}},
|
|
},
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
member, err := accesslist.NewAccessListMember(
|
|
header.Metadata{Name: "testuser1"},
|
|
accesslist.AccessListMemberSpec{
|
|
AccessList: acl.GetName(),
|
|
Name: "testuser1",
|
|
Joined: time.Now(),
|
|
Expires: time.Now().Add(24 * time.Hour),
|
|
Reason: "test",
|
|
AddedBy: "admin",
|
|
},
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
_, _, err = a.UpsertAccessListWithMembers(ctx, acl, []*accesslist.AccessListMember{member})
|
|
require.NoError(t, err)
|
|
|
|
t.Cleanup(func() {
|
|
a.DeleteAccessList(ctx, acl.GetName())
|
|
})
|
|
},
|
|
assertion: func(t *testing.T, resp *authclient.GithubAuthResponse, userState services.UserState, err error) {
|
|
require.NotNil(t, resp)
|
|
require.NoError(t, err)
|
|
require.Contains(t, userState.GetRoles(), "access")
|
|
require.WithinDuration(t, time.Now().Add(defaults.MinCertDuration/2), resp.Session.GetExpiryTime(), time.Second*5)
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.testName, func(t *testing.T) {
|
|
req, err := proxyClient.CreateGithubAuthRequest(ctx, types.GithubAuthRequest{
|
|
ConnectorID: connector.GetName(),
|
|
Type: constants.Github,
|
|
CertTTL: defaults.MinCertDuration,
|
|
CreateWebSession: tt.createWebSession,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
if tt.setup != nil {
|
|
tt.setup(t)
|
|
}
|
|
|
|
resp, err := proxyClient.ValidateGithubAuthCallback(ctx, url.Values{
|
|
"code": []string{"test-code"},
|
|
"state": []string{req.StateToken},
|
|
})
|
|
|
|
userState, userErr := a.GetUserOrLoginState(ctx, "testuser1")
|
|
require.NoError(t, userErr)
|
|
|
|
tt.assertion(t, resp, userState, err)
|
|
})
|
|
}
|
|
}
|