Ctrl K
ComunidadGuías…

tests/identity_access_test.go

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package main

import (
	"context"
	"net"
	"regexp"
	"strings"
	"testing"
	"time"

	"github.com/shellhub-io/shellhub/pkg/api/authorizer"
	"github.com/shellhub-io/shellhub/pkg/api/requests"
	"github.com/shellhub-io/shellhub/pkg/models"
	"github.com/shellhub-io/shellhub/tests/environment"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	"golang.org/x/crypto/ssh"
)

const approvalWait = 60 * time.Second

func dialSSH(ctx context.Context, addr, sshid string, signers []ssh.Signer, prompts chan<- string, answers <-chan string) error {
	auth := []ssh.AuthMethod{ssh.PublicKeysCallback(func() ([]ssh.Signer, error) { return signers, nil })}

	if prompts != nil {
		auth = append(auth, ssh.KeyboardInteractive(func(_, instruction string, questions []string, _ []bool) ([]string, error) {
			select {
			case prompts <- instruction:
			default:
			}

			if len(questions) == 0 {
				return nil, nil
			}

			if answers == nil {
				return make([]string, len(questions)), nil
			}

			select {
			case answer := <-answers:
				return []string{answer}, nil
			case <-ctx.Done():
				return nil, ctx.Err()
			}
		}))
	}

	return handshake(ctx, addr, sshid, auth)
}

func handshake(ctx context.Context, addr, sshid string, auth []ssh.AuthMethod) error {
	return handshakeWith(ctx, addr, &ssh.ClientConfig{
		User:            sshid,
		Auth:            auth,
		HostKeyCallback: ssh.InsecureIgnoreHostKey(), //nolint:gosec // a test dialing its own throwaway server
	})
}

func handshakeWith(ctx context.Context, addr string, config *ssh.ClientConfig) error {
	dialer := new(net.Dialer)

	conn, err := dialer.DialContext(ctx, "tcp", addr)
	if err != nil {
		return err
	}

	stop := context.AfterFunc(ctx, func() { _ = conn.Close() })
	defer stop()

	c, chans, reqs, err := ssh.NewClientConn(conn, addr, config)
	if err != nil {
		_ = conn.Close()

		return err
	}

	_ = ssh.NewClient(c, chans, reqs).Close()

	return nil
}

func enrollIdentity(ctx context.Context, t *testing.T, compose *environment.DockerCompose, publicKey string) {
	t.Helper()

	resp, err := compose.R(ctx).
		SetBody(map[string]string{"name": "enrolled", "data": publicKey, "source": "manual"}).
		Post("/api/ssh-identities")
	require.NoError(t, err)
	require.Contains(t, []int{200, 201}, resp.StatusCode(), "failed to enroll the identity: %s", resp.String())
}

func requireLoginRefused(t *testing.T, compose *environment.DockerCompose, device *models.Device, signer ssh.Signer, wantServerLogs ...string) {
	t.Helper()

	err := dialSSH(t.Context(), compose.SSHAddress(), deviceSSHID(device), []ssh.Signer{signer}, nil, nil)
	require.Error(t, err)

	for _, substr := range wantServerLogs {
		compose.AwaitServerLog(t, substr)
	}
}

var approvalCodePattern = regexp.MustCompile(`/ssh-identities/new/([2-9A-Z]{8})`)

// TestIdentityAccessPolicy covers what decides a login in a new namespace:
// an unknown key is held for approval and let in once confirmed, default-deny leaves a member
// without a grant outside, a deny beats the allow that would otherwise let the owner in, and a
// namespace stripped of every policy admits nobody. Password refusal and a pre-enrolled identity
// connecting are covered by [TestSSHNewNamespace].
func TestIdentityAccessPolicy(t *testing.T) {
	t.Parallel()

	t.Run("an unknown key is held for approval and let in once confirmed", func(t *testing.T) {
		ctx := context.Background()
		compose := newSSHEnvironment(t, ctx, newNamespace)
		_, device := startAcceptedAgent(t, ctx, compose)

		signer, _ := newSigner(t)

		prompts := make(chan string, 8)
		answers := make(chan string, 1)
		dialed := make(chan error, 1)

		go func() {
			dialed <- dialSSH(ctx, compose.SSHAddress(), deviceSSHID(device), []ssh.Signer{signer}, prompts, answers)
		}()

		var code string

		deadline := time.After(approvalWait)

		for code == "" {
			select {
			case instruction := <-prompts:
				if match := approvalCodePattern.FindStringSubmatch(instruction); match != nil {
					code = match[1]
				}
			case err := <-dialed:
				require.NoError(t, err, "the login failed before an approval code was offered")
				require.Fail(t, "the login completed without an approval code being offered")
			case <-deadline:
				require.Fail(t, "the gateway never offered an approval code")
			}
		}

		confirmation := new(models.SSHApprovalConfirmation)
		resp, err := compose.R(ctx).SetResult(confirmation).Post("/api/ssh-approvals/" + code + "/confirm")
		require.NoError(t, err)
		require.Equal(t, 200, resp.StatusCode())
		require.NotEmpty(t, confirmation.ConfirmationCode,
			"confirming has to hand back a code, since that is what the terminal asks for")

		answers <- confirmation.ConfirmationCode

		select {
		case err := <-dialed:
			require.NoError(t, err, "the login should resume once the confirmation code is typed")
		case <-time.After(approvalWait):
			require.Fail(t, "the login never resumed after the approval was confirmed")
		}

		identities := []models.SSHIdentity{}

		resp, err = compose.R(ctx).SetResult(&identities).Get("/api/ssh-identities")
		require.NoError(t, err)
		require.Equal(t, 200, resp.StatusCode())
		require.Len(t, identities, 1)
		assert.Equal(t, models.SSHIdentitySourceApproval, identities[0].Source)
		assert.Equal(t, ssh.FingerprintSHA256(signer.PublicKey()), identities[0].Fingerprint,
			"the identity should hold the key that dialled")
	})

	t.Run("a client that cannot answer a challenge is refused at once", func(t *testing.T) {
		ctx := context.Background()
		compose := newSSHEnvironment(t, ctx, newNamespace)
		_, device := startAcceptedAgent(t, ctx, compose)

		signer, _ := newSigner(t)

		started := time.Now() //nolint:forbidigo // elapsed time of the login, which is what this test asserts on
		err := dialSSH(ctx, compose.SSHAddress(), deviceSSHID(device), []ssh.Signer{signer}, nil, nil)
		elapsed := time.Since(started)

		require.Error(t, err, "an unenrolled key must not get in without an approval")

		assert.Less(t, elapsed, 5*time.Second,
			"a client with no way to answer must be refused, not held while someone is asked")
	})

	t.Run("an agent full of unenrolled keys is asked once and still gets in", func(t *testing.T) {
		ctx := context.Background()
		compose := newSSHEnvironment(t, ctx, newNamespace)
		_, device := startAcceptedAgent(t, ctx, compose)

		enrolled, enrolledKey := newSigner(t)
		enrollIdentity(ctx, t, compose, enrolledKey)

		first, _ := newSigner(t)
		second, _ := newSigner(t)

		prompts := make(chan string, 8)
		dialed := make(chan error, 1)

		go func() {
			dialed <- dialSSH(ctx, compose.SSHAddress(), deviceSSHID(device),
				[]ssh.Signer{first, second, enrolled}, prompts, nil)
		}()

		select {
		case err := <-dialed:
			require.NoError(t, err, "the enrolled key behind the unenrolled ones should still get in")
		case <-time.After(approvalWait):
			require.Fail(t, "the login never finished")
		}

		close(prompts)

		asked := map[string]bool{}
		for instruction := range prompts {
			if strings.Contains(instruction, "doesn't know this SSH key") {
				asked[instruction] = true
			}
		}

		assert.Len(t, asked, 1,
			"one connection is asked about one key, however many unenrolled ones it holds")
	})

	tests := []struct {
		name           string
		refuse         func(t *testing.T, compose *environment.DockerCompose) ssh.Signer
		wantServerLogs []string
	}{
		{
			name: "a member no policy grants is refused",
			refuse: func(t *testing.T, compose *environment.DockerCompose) ssh.Signer {
				t.Helper()

				compose.NewUser(t, "member", "member@ossystems.com.br", ShellHubPassword)
				compose.NewMember(t, "member", ShellHubNamespaceName, string(authorizer.RoleOperator))

				auth := compose.AuthUser(t, "member", ShellHubPassword)
				require.NotNil(t, auth.Tenant)
				require.Equal(t, ShellHubNamespace, *auth.Tenant)

				signer, data := newSigner(t)
				compose.EnrollIdentityAs(t, auth.Token, "member", data)

				return signer
			},
			wantServerLogs: []string{"reason=" + string(models.ReasonNoGrant)},
		},
		{
			name: "a deny policy beats the allow that would grant the owner",
			refuse: func(t *testing.T, compose *environment.DockerCompose) ssh.Signer {
				t.Helper()

				signer, data := newSigner(t)
				compose.EnrollIdentity(t, "owner", data)

				compose.CreateAccessPolicy(t, &requests.AccessPolicyCreate{
					Name:    "no root",
					Subject: requests.AccessPolicySubject{Type: string(models.PolicySubjectAllMembers)},
					Filter:  requests.AccessPolicyFilter{All: true},
					Logins:  []string{ShellHubAgentUsername},
					Action:  string(models.PolicyActionDeny),
				})

				return signer
			},
			wantServerLogs: []string{"reason=" + string(models.ReasonDeniedByPolicy)},
		},
		{
			name: "a namespace with no policy at all refuses everyone",
			refuse: func(t *testing.T, compose *environment.DockerCompose) ssh.Signer {
				t.Helper()

				signer, data := newSigner(t)
				compose.EnrollIdentity(t, "owner", data)

				policies := []models.AccessPolicy{}

				resp, err := compose.R(t.Context()).SetResult(&policies).Get("/api/access-policies")
				require.NoError(t, err)
				require.Equal(t, 200, resp.StatusCode())
				require.NotEmpty(t, policies, "a new namespace is seeded with the owner policy")

				for _, policy := range policies {
					resp, err := compose.R(t.Context()).Delete("/api/access-policies/" + policy.ID)
					require.NoError(t, err)
					require.Equal(t, 200, resp.StatusCode())
				}

				return signer
			},
			wantServerLogs: []string{
				"destination device did not pass the connection evaluation",
				`error="ssh access denied by policy"`,
			},
		},
	}

	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			ctx := context.Background()
			compose := newSSHEnvironment(t, ctx, newNamespace)
			_, device := startAcceptedAgent(t, ctx, compose)

			signer := tc.refuse(t, compose)

			requireLoginRefused(t, compose, device, signer, tc.wantServerLogs...)
		})
	}
}