mirror of
https://github.com/gravitational/teleport.git
synced 2026-10-25 10:40:02 +08:00
* Migrate from mfa v1 to v2. Signed-off-by: Chris Thach <chris.thach@goteleport.com> * Set deprecated proto option and update migration comment. Signed-off-by: Chris Thach <chris.thach@goteleport.com> * Reduce nolint directives. Signed-off-by: Chris Thach <chris.thach@goteleport.com> * Tag Ryan for the Desktop TODO. --------- Signed-off-by: Chris Thach <chris.thach@goteleport.com>
477 lines
15 KiB
Go
477 lines
15 KiB
Go
/*
|
|
* 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 git
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"log/slog"
|
|
"net"
|
|
"os"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/gravitational/trace"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"golang.org/x/crypto/ssh"
|
|
|
|
"github.com/gravitational/teleport/api/client/gitserver"
|
|
"github.com/gravitational/teleport/api/constants"
|
|
mfav2 "github.com/gravitational/teleport/api/gen/proto/go/teleport/mfa/v2"
|
|
tracessh "github.com/gravitational/teleport/api/observability/tracing/ssh"
|
|
apissh "github.com/gravitational/teleport/api/ssh"
|
|
"github.com/gravitational/teleport/api/types"
|
|
apievents "github.com/gravitational/teleport/api/types/events"
|
|
"github.com/gravitational/teleport/api/types/wrappers"
|
|
apisshutils "github.com/gravitational/teleport/api/utils/sshutils"
|
|
"github.com/gravitational/teleport/lib/auth/authclient"
|
|
"github.com/gravitational/teleport/lib/auth/testauthority"
|
|
"github.com/gravitational/teleport/lib/backend/memory"
|
|
"github.com/gravitational/teleport/lib/cryptosuites"
|
|
libevents "github.com/gravitational/teleport/lib/events"
|
|
"github.com/gravitational/teleport/lib/events/eventstest"
|
|
"github.com/gravitational/teleport/lib/services"
|
|
"github.com/gravitational/teleport/lib/services/local"
|
|
"github.com/gravitational/teleport/lib/srv"
|
|
"github.com/gravitational/teleport/lib/sshca"
|
|
"github.com/gravitational/teleport/lib/sshutils"
|
|
"github.com/gravitational/teleport/lib/utils"
|
|
"github.com/gravitational/teleport/lib/utils/log/logtest"
|
|
)
|
|
|
|
func TestMain(m *testing.M) {
|
|
logtest.InitLogger(testing.Verbose)
|
|
os.Exit(m.Run())
|
|
}
|
|
|
|
func TestForwardServer(t *testing.T) {
|
|
caSigner, err := apisshutils.MakeTestSSHCA()
|
|
require.NoError(t, err)
|
|
userCert := makeUserCert(t, caSigner)
|
|
|
|
tests := []struct {
|
|
name string
|
|
allowedGitHubOrg string
|
|
clientLogin string
|
|
verifyRemoteHost ssh.HostKeyCallback
|
|
wantNewClientError bool
|
|
verifyWithClient func(t *testing.T, ctx context.Context, client *tracessh.Client, m *mockGitHostingService)
|
|
verifyEvent func(t *testing.T, event apievents.AuditEvent)
|
|
}{
|
|
{
|
|
name: "success",
|
|
allowedGitHubOrg: "*",
|
|
clientLogin: "git",
|
|
verifyRemoteHost: ssh.InsecureIgnoreHostKey(),
|
|
wantNewClientError: false,
|
|
verifyWithClient: func(t *testing.T, ctx context.Context, client *tracessh.Client, m *mockGitHostingService) {
|
|
session, err := client.NewSession(ctx)
|
|
require.NoError(t, err)
|
|
defer session.Close()
|
|
|
|
gitCommand := "git-upload-pack 'org/my-repo.git'"
|
|
session.Stderr = io.Discard
|
|
session.Stdout = io.Discard
|
|
err = session.Run(ctx, gitCommand)
|
|
require.NoError(t, err)
|
|
require.Equal(t, gitCommand, m.receivedExec.Command)
|
|
},
|
|
verifyEvent: func(t *testing.T, event apievents.AuditEvent) {
|
|
gitEvent, ok := event.(*apievents.GitCommand)
|
|
require.True(t, ok)
|
|
assert.Equal(t, libevents.GitCommandEvent, gitEvent.Metadata.Type)
|
|
assert.Equal(t, libevents.GitCommandCode, gitEvent.Metadata.Code)
|
|
assert.NotEmpty(t, gitEvent.SessionID)
|
|
assert.Equal(t, "alice", gitEvent.User)
|
|
assert.Equal(t, "0", gitEvent.CommandMetadata.ExitCode)
|
|
assert.Equal(t, "git-upload-pack", gitEvent.Service)
|
|
assert.Equal(t, "org/my-repo.git", gitEvent.Path)
|
|
},
|
|
},
|
|
{
|
|
name: "command failure",
|
|
allowedGitHubOrg: "*",
|
|
clientLogin: "git",
|
|
verifyRemoteHost: ssh.InsecureIgnoreHostKey(),
|
|
wantNewClientError: false,
|
|
verifyWithClient: func(t *testing.T, ctx context.Context, client *tracessh.Client, m *mockGitHostingService) {
|
|
m.exitCode = 1
|
|
|
|
session, err := client.NewSession(ctx)
|
|
require.NoError(t, err)
|
|
defer session.Close()
|
|
|
|
gitCommand := "git-receive-pack 'org/my-repo.git'"
|
|
session.Stderr = io.Discard
|
|
session.Stdout = io.Discard
|
|
require.Error(t, session.Run(ctx, gitCommand))
|
|
},
|
|
verifyEvent: func(t *testing.T, event apievents.AuditEvent) {
|
|
gitEvent, ok := event.(*apievents.GitCommand)
|
|
require.True(t, ok)
|
|
assert.Equal(t, libevents.GitCommandEvent, gitEvent.Metadata.Type)
|
|
assert.Equal(t, libevents.GitCommandFailureCode, gitEvent.Metadata.Code)
|
|
assert.Equal(t, "alice", gitEvent.User)
|
|
assert.Equal(t, "1", gitEvent.CommandMetadata.ExitCode)
|
|
assert.Equal(t, "git-receive-pack", gitEvent.Service)
|
|
assert.Equal(t, "org/my-repo.git", gitEvent.Path)
|
|
},
|
|
},
|
|
{
|
|
name: "failed RBAC",
|
|
allowedGitHubOrg: "no-org-allowed",
|
|
clientLogin: "git",
|
|
verifyRemoteHost: ssh.InsecureIgnoreHostKey(),
|
|
wantNewClientError: true,
|
|
verifyEvent: func(t *testing.T, event apievents.AuditEvent) {
|
|
authFailureEvent, ok := event.(*apievents.AuthAttempt)
|
|
require.True(t, ok)
|
|
assert.Equal(t, libevents.AuthAttemptEvent, authFailureEvent.Metadata.Type)
|
|
assert.Equal(t, libevents.AuthAttemptFailureCode, authFailureEvent.Metadata.Code)
|
|
assert.Equal(t, "alice", authFailureEvent.User)
|
|
assert.Contains(t, authFailureEvent.Error, "access denied")
|
|
},
|
|
},
|
|
{
|
|
name: "failed client login check",
|
|
allowedGitHubOrg: "*",
|
|
clientLogin: "not-git",
|
|
verifyRemoteHost: ssh.InsecureIgnoreHostKey(),
|
|
wantNewClientError: true,
|
|
},
|
|
{
|
|
name: "failed remote host check",
|
|
allowedGitHubOrg: "*",
|
|
clientLogin: "git",
|
|
verifyRemoteHost: func(string, net.Addr, ssh.PublicKey) error {
|
|
return trace.AccessDenied("fake a remote host check error")
|
|
},
|
|
verifyWithClient: func(t *testing.T, ctx context.Context, client *tracessh.Client, m *mockGitHostingService) {
|
|
// Connection is accepted but anything following fails.
|
|
_, err := client.NewSession(ctx)
|
|
require.Error(t, err)
|
|
},
|
|
},
|
|
{
|
|
name: "invalid channel type",
|
|
allowedGitHubOrg: "*",
|
|
clientLogin: "git",
|
|
verifyRemoteHost: ssh.InsecureIgnoreHostKey(),
|
|
verifyWithClient: func(t *testing.T, ctx context.Context, client *tracessh.Client, m *mockGitHostingService) {
|
|
_, _, err := client.OpenChannel(ctx, "unknown", nil)
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), "unknown channel type")
|
|
},
|
|
},
|
|
{
|
|
name: "org mismatch",
|
|
allowedGitHubOrg: "*",
|
|
clientLogin: "git",
|
|
verifyRemoteHost: ssh.InsecureIgnoreHostKey(),
|
|
wantNewClientError: false,
|
|
verifyWithClient: func(t *testing.T, ctx context.Context, client *tracessh.Client, m *mockGitHostingService) {
|
|
session, err := client.NewSession(ctx)
|
|
require.NoError(t, err)
|
|
defer session.Close()
|
|
|
|
gitCommand := "git-upload-pack 'some-other-org/my-repo.git'"
|
|
session.Stderr = io.Discard
|
|
session.Stdout = io.Discard
|
|
require.Error(t, session.Run(ctx, gitCommand))
|
|
},
|
|
verifyEvent: func(t *testing.T, event apievents.AuditEvent) {
|
|
gitEvent, ok := event.(*apievents.GitCommand)
|
|
require.True(t, ok)
|
|
assert.Equal(t, libevents.GitCommandEvent, gitEvent.Metadata.Type)
|
|
assert.Equal(t, libevents.GitCommandFailureCode, gitEvent.Metadata.Code)
|
|
assert.Equal(t, "alice", gitEvent.User)
|
|
assert.Contains(t, gitEvent.Error, "expect organization")
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := t.Context()
|
|
|
|
mockEmitter := &eventstest.MockRecorderEmitter{}
|
|
mockGitService := newMockGitHostingService(t, caSigner)
|
|
hostCert, err := apisshutils.MakeRealHostCert(caSigner)
|
|
require.NoError(t, err)
|
|
targetConn, err := net.Dial("tcp", mockGitService.Addr())
|
|
require.NoError(t, err)
|
|
|
|
s, err := NewForwardServer(&ForwardServerConfig{
|
|
TargetServer: makeGitServer(t, "org"),
|
|
TargetConn: targetConn,
|
|
AuthClient: mockAuthClient{},
|
|
AccessPoint: mockAccessPoint{
|
|
ca: caSigner,
|
|
allowedGitHubOrg: test.allowedGitHubOrg,
|
|
},
|
|
Emitter: mockEmitter,
|
|
HostCertificate: hostCert,
|
|
ParentContext: ctx,
|
|
LockWatcher: makeLockWatcher(t),
|
|
SrcAddr: utils.MustParseAddr("127.0.0.1:12345"),
|
|
DstAddr: utils.MustParseAddr("127.0.0.1:2222"),
|
|
// Not used in test, yet.
|
|
KeyManager: new(KeyManager),
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
s.verifyRemoteHost = test.verifyRemoteHost
|
|
s.makeRemoteSigner = func(context.Context, *ForwardServerConfig, srv.IdentityContext) (ssh.Signer, error) {
|
|
// mock server does not validate this, just put whatever.
|
|
return userCert, nil
|
|
}
|
|
go s.Serve()
|
|
|
|
clientDialConn, err := s.Dial()
|
|
require.NoError(t, err)
|
|
|
|
conn, chCh, reqCh, err := apissh.NewClientConn(
|
|
t.Context(),
|
|
clientDialConn,
|
|
"127.0.0.1:222",
|
|
apissh.ClientConfig{
|
|
User: test.clientLogin,
|
|
PublicKeyAuth: apissh.PublicKeyAuthConfig{
|
|
Signers: func() ([]ssh.Signer, error) {
|
|
return []ssh.Signer{userCert}, nil
|
|
},
|
|
},
|
|
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
|
Timeout: 5 * time.Second,
|
|
},
|
|
)
|
|
if test.wantNewClientError {
|
|
require.Error(t, err)
|
|
return
|
|
}
|
|
require.NoError(t, err)
|
|
client := tracessh.NewClient(conn, chCh, reqCh)
|
|
defer client.Close()
|
|
|
|
test.verifyWithClient(t, ctx, client, mockGitService)
|
|
if test.verifyEvent != nil {
|
|
// Server emits exec event after sending result to client.
|
|
require.EventuallyWithT(t, func(t *assert.CollectT) {
|
|
require.NotNil(t, mockEmitter.LastEvent())
|
|
}, time.Second*2, time.Millisecond*100, "Timeout waiting for audit event.")
|
|
test.verifyEvent(t, mockEmitter.LastEvent())
|
|
}
|
|
})
|
|
}
|
|
|
|
}
|
|
|
|
func makeUserCert(t *testing.T, caSigner ssh.Signer) ssh.Signer {
|
|
t.Helper()
|
|
clientPrivateKey, err := cryptosuites.GeneratePrivateKeyWithAlgorithm(cryptosuites.ECDSAP256)
|
|
require.NoError(t, err)
|
|
clientCertBytes, err := testauthority.GenerateUserCert(sshca.UserCertificateRequest{
|
|
CASigner: caSigner,
|
|
PublicUserKey: clientPrivateKey.MarshalSSHPublicKey(),
|
|
CertificateFormat: constants.CertificateFormatStandard,
|
|
Identity: sshca.Identity{
|
|
Username: "alice",
|
|
Principals: []string{"does-not-matter"},
|
|
GitHubUserID: "1234567",
|
|
Traits: wrappers.Traits{},
|
|
Roles: []string{"editor"},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
clientAuthorizedCert, _, _, _, err := ssh.ParseAuthorizedKey(clientCertBytes)
|
|
require.NoError(t, err)
|
|
clientSigner, err := apisshutils.SSHSigner(clientAuthorizedCert.(*ssh.Certificate), clientPrivateKey)
|
|
require.NoError(t, err)
|
|
return clientSigner
|
|
}
|
|
|
|
func makeLockWatcher(t *testing.T) *services.LockWatcher {
|
|
t.Helper()
|
|
backend, err := memory.New(memory.Config{})
|
|
require.NoError(t, err)
|
|
lockWatcher, err := services.NewLockWatcher(context.Background(), services.LockWatcherConfig{
|
|
ResourceWatcherConfig: services.ResourceWatcherConfig{
|
|
Component: "git.test",
|
|
Client: local.NewEventsService(backend),
|
|
},
|
|
LockGetter: local.NewAccessService(backend),
|
|
})
|
|
require.NoError(t, err)
|
|
return lockWatcher
|
|
}
|
|
|
|
func makeGitServer(t *testing.T, org string) types.Server {
|
|
t.Helper()
|
|
server, err := types.NewGitHubServer(types.GitHubServerMetadata{
|
|
Integration: org,
|
|
Organization: org,
|
|
})
|
|
require.NoError(t, err)
|
|
return server
|
|
}
|
|
|
|
type mockGitHostingService struct {
|
|
*sshutils.Server
|
|
*sshutils.Reply
|
|
receivedExec sshutils.ExecReq
|
|
exitCode int
|
|
hostKey ssh.PublicKey
|
|
}
|
|
|
|
func newMockGitHostingService(t *testing.T, caSigner ssh.Signer) *mockGitHostingService {
|
|
t.Helper()
|
|
hostCert, err := apisshutils.MakeRealHostCert(caSigner)
|
|
require.NoError(t, err)
|
|
m := &mockGitHostingService{
|
|
Reply: &sshutils.Reply{},
|
|
hostKey: hostCert.PublicKey(),
|
|
}
|
|
server, err := sshutils.NewServer(
|
|
"git.test",
|
|
utils.NetAddr{AddrNetwork: "tcp", Addr: "localhost:0"},
|
|
m,
|
|
sshutils.StaticHostSigners(hostCert),
|
|
sshutils.AuthMethods{NoClient: true},
|
|
sshutils.SetNewConnHandler(m),
|
|
)
|
|
require.NoError(t, err)
|
|
require.NoError(t, server.Start())
|
|
t.Cleanup(func() {
|
|
server.Close()
|
|
})
|
|
m.Server = server
|
|
return m
|
|
}
|
|
func (m *mockGitHostingService) HandleNewConn(ctx context.Context, ccx *sshutils.ConnectionContext) (context.Context, error) {
|
|
slog.DebugContext(ctx, "mock git service receives new connection")
|
|
return ctx, nil
|
|
}
|
|
func (m *mockGitHostingService) HandleNewChan(ctx context.Context, ccx *sshutils.ConnectionContext, nch ssh.NewChannel) {
|
|
slog.DebugContext(ctx, "mock git service receives new chan")
|
|
ch, in, err := nch.Accept()
|
|
if err != nil {
|
|
m.RejectWithAcceptError(ctx, nch, err)
|
|
return
|
|
}
|
|
defer ch.Close()
|
|
for {
|
|
select {
|
|
case req := <-in:
|
|
if req == nil {
|
|
return
|
|
}
|
|
|
|
if err := ssh.Unmarshal(req.Payload, &m.receivedExec); err != nil {
|
|
m.ReplyError(ctx, req, err)
|
|
return
|
|
}
|
|
if req.WantReply {
|
|
m.ReplyRequest(ctx, req, true, nil)
|
|
}
|
|
slog.DebugContext(ctx, "mock git service receives new exec request", "req", m.receivedExec)
|
|
m.SendExitStatus(ctx, ch, m.exitCode)
|
|
return
|
|
|
|
case <-ctx.Done():
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
type mockAuthClient struct {
|
|
authclient.ClientI
|
|
events types.Events
|
|
}
|
|
|
|
func (m mockAuthClient) NewWatcher(ctx context.Context, watch types.Watch) (types.Watcher, error) {
|
|
if m.events == nil {
|
|
return nil, trace.AccessDenied("unauthorized")
|
|
}
|
|
return m.events.NewWatcher(ctx, watch)
|
|
}
|
|
|
|
type mockMFAServiceClient struct {
|
|
mfav2.MFAServiceClient
|
|
}
|
|
|
|
func (m mockAuthClient) MFAServiceClientV2() mfav2.MFAServiceClient {
|
|
return &mockMFAServiceClient{}
|
|
}
|
|
|
|
type mockAccessPoint struct {
|
|
srv.AccessPoint
|
|
ca ssh.Signer
|
|
allowedGitHubOrg string
|
|
services.GitServers
|
|
}
|
|
|
|
func (m mockAccessPoint) GetClusterName(ctx context.Context) (types.ClusterName, error) {
|
|
return types.NewClusterName(types.ClusterNameSpecV2{
|
|
ClusterName: "git.test",
|
|
ClusterID: "git.test",
|
|
})
|
|
}
|
|
func (m mockAccessPoint) GetClusterNetworkingConfig(context.Context) (types.ClusterNetworkingConfig, error) {
|
|
return types.DefaultClusterNetworkingConfig(), nil
|
|
}
|
|
func (m mockAccessPoint) GetSessionRecordingConfig(context.Context) (types.SessionRecordingConfig, error) {
|
|
return types.DefaultSessionRecordingConfig(), nil
|
|
}
|
|
func (m mockAccessPoint) GetAuthPreference(context.Context) (types.AuthPreference, error) {
|
|
return types.DefaultAuthPreference(), nil
|
|
}
|
|
func (m mockAccessPoint) GetRole(_ context.Context, name string) (types.Role, error) {
|
|
return types.NewRole(name, types.RoleSpecV6{
|
|
Allow: types.RoleConditions{
|
|
GitHubPermissions: []types.GitHubPermission{{
|
|
Organizations: []string{m.allowedGitHubOrg},
|
|
}},
|
|
},
|
|
})
|
|
}
|
|
func (m mockAccessPoint) GetCertAuthorities(_ context.Context, caType types.CertAuthType, _ bool) ([]types.CertAuthority, error) {
|
|
if m.ca == nil {
|
|
return nil, trace.NotFound("no certificate authority found")
|
|
}
|
|
ca, err := types.NewCertAuthority(types.CertAuthoritySpecV2{
|
|
Type: caType,
|
|
ClusterName: "git.test",
|
|
ActiveKeys: types.CAKeySet{
|
|
SSH: []*types.SSHKeyPair{{
|
|
PublicKey: ssh.MarshalAuthorizedKey(m.ca.PublicKey()),
|
|
}},
|
|
},
|
|
})
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
return []types.CertAuthority{ca}, nil
|
|
}
|
|
func (m mockAccessPoint) GitServerReadOnlyClient() gitserver.ReadOnlyClient {
|
|
return m.GitServers
|
|
}
|