Ctrl K
ComunidadGuías…

tests/identity_connection_test.go

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

Archivo original ↗Preguntar a KiBot
package main

import (
	"context"
	"sync"
	"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/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	"golang.org/x/crypto/ssh"
)

const deadIdentityLog = "failed to resolve the identity for the public key"

// TestSSHIdentityConnection covers what an enrolled identity does at connect time once it exists:
// it stops working when it expires or when its one session burns it, every connection stamps it,
// and it authenticates as the principal it is bound to and nobody else. Enrolling a key and the
// first connection with it are covered by [TestSSHNewNamespace] and [TestIdentityAccessPolicy].
func TestSSHIdentityConnection(t *testing.T) {
	t.Parallel()

	ctx := context.Background()
	compose := newSSHEnvironment(t, ctx, newNamespace)
	_, device := startAcceptedAgent(t, ctx, compose)

	sshid := deviceSSHID(device)

	t.Run("an expired identity is refused instead of being offered for enrollment again", func(t *testing.T) {
		signer, data := newSigner(t)
		fingerprint := ssh.FingerprintSHA256(signer.PublicKey())

		days := 1
		resp, err := compose.R(t.Context()).
			SetBody(&requests.SSHIdentityCreate{Name: "expiring", Data: data, ExpiresIn: &days}).
			Post("/api/ssh-identities")
		require.NoError(t, err)
		require.Equal(t, 200, resp.StatusCode(), resp.String())

		requireStraightThrough(t, compose, sshid, signer)

		expireIdentityAndRequireRefused(t, compose, sshid, signer)

		identity := identityByFingerprint(t, compose, fingerprint)
		assert.False(t, identity.Active(time.Now()), //nolint:forbidigo // the expiry the server compares against its own wall clock
			"the identity should still be on record, and dead")
	})

	t.Run("a single-use identity is consumed by its first session", func(t *testing.T) {
		key, signer := newAPIKeyIdentity(t, compose, "oneshot", true)
		grant(t, compose, &requests.AccessPolicyCreate{
			Name:    "oneshot",
			Subject: apiKeySubject(key.ID),
			Filter:  requests.AccessPolicyFilter{All: true},
			Logins:  []string{"*"},
		})

		fresh := apiKeyIdentity(t, compose, "oneshot")
		require.True(t, fresh.SingleUse)
		require.Nil(t, fresh.ConsumedAt)

		requireStraightThrough(t, compose, sshid, signer)

		requireRecent(t, apiKeyIdentity(t, compose, "oneshot").ConsumedAt)
	})

	t.Run("a consumed single-use identity is refused", func(t *testing.T) {
		key, signer := newAPIKeyIdentity(t, compose, "burnt", true)
		grant(t, compose, &requests.AccessPolicyCreate{
			Name:    "burnt",
			Subject: apiKeySubject(key.ID),
			Filter:  requests.AccessPolicyFilter{All: true},
			Logins:  []string{"*"},
		})

		requireStraightThrough(t, compose, sshid, signer)
		require.NotNil(t, apiKeyIdentity(t, compose, "burnt").ConsumedAt)

		mark := compose.ServerLogMark(t)

		requireRefusedAtAuth(t, compose, sshid, signer)

		compose.AwaitServerLogLine(t, mark, deadIdentityLog, `error="ssh access denied by policy"`)
	})

	t.Run("a single-use identity authenticates one of its concurrent logins", func(t *testing.T) {
		key, signer := newAPIKeyIdentity(t, compose, "contended", true)
		grant(t, compose, &requests.AccessPolicyCreate{
			Name:    "contended",
			Subject: apiKeySubject(key.ID),
			Filter:  requests.AccessPolicyFilter{All: true},
			Logins:  []string{"*"},
		})

		const logins = 8

		start := make(chan struct{})
		results := make(chan error, logins)

		var wg sync.WaitGroup
		for range logins {
			spare, _ := newSigner(t)

			wg.Go(func() {
				<-start
				results <- dialSSH(t.Context(), compose.SSHAddress(), sshid, []ssh.Signer{signer, spare}, nil, nil)
			})
		}

		close(start)
		wg.Wait()
		close(results)

		authenticated := 0
		for err := range results {
			if err != nil {
				require.ErrorContains(t, err, "unable to authenticate")

				continue
			}

			authenticated++
		}

		assert.Equal(t, 1, authenticated)
		requireRecent(t, apiKeyIdentity(t, compose, "contended").ConsumedAt)
	})

	t.Run("every connection stamps the identity's last use", func(t *testing.T) {
		signer, fingerprint := enrollOwnerKey(t, compose)
		require.Nil(t, identityByFingerprint(t, compose, fingerprint).LastUsedAt)

		requireStraightThrough(t, compose, sshid, signer)
		requireRecent(t, identityByFingerprint(t, compose, fingerprint).LastUsedAt)

		compose.AgeSSHIdentityLastUse(t, fingerprint, 24*time.Hour)

		aged := identityByFingerprint(t, compose, fingerprint).LastUsedAt
		require.NotNil(t, aged)
		require.WithinDuration(t, time.Now().Add(-24*time.Hour), *aged, time.Minute) //nolint:forbidigo // the stamp was aged with the database's wall clock

		requireStraightThrough(t, compose, sshid, signer)
		requireRecent(t, identityByFingerprint(t, compose, fingerprint).LastUsedAt)
	})

	t.Run("a member's key authenticates as that member, not as the owner a policy grants", func(t *testing.T) {
		member := newMember(t, compose, "crossing", authorizer.RoleOperator)

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

		ownerSigner, _ := enrollOwnerKey(t, compose)
		requireStraightThrough(t, compose, sshid, ownerSigner)

		mark := compose.ServerLogMark(t)

		requireRefusedAtAuth(t, compose, sshid, memberSigner)

		compose.AwaitServerLogLine(t, mark, "reason="+string(models.ReasonNoGrant), "user="+member.ID)

		identity := identityByFingerprint(t, compose, ssh.FingerprintSHA256(memberSigner.PublicKey()))
		assert.Equal(t, member.ID, identity.PrincipalID)
	})
}