Ctrl K
ComunidadGuías…

tests/identity_policy_test.go

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

Archivo original ↗Preguntar a KiBot
package main

import (
	"context"
	"net/http"
	"net/netip"
	"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 unroutableCIDR = "203.0.113.0/24"

// TestAccessPolicyEvaluation covers what each part of an access policy matches: the subject, by
// person, role, every member or API key; the device, by tag; the login; and the source address.
// It also covers the three refusals no policy can lift: a person outside the namespace, a member
// whose role cannot connect, and a deny that cannot be read. A policy naming only principals that
// can never connect is refused when its subject is such a role and flagged as matching nobody when
// it names such a person or an expired API key. Each case reaches the device as someone only its
// own policies grant, so the owner's starter policy never decides it. Default-deny, deny-over-allow
// and a member with no grant are covered by [TestIdentityAccessPolicy].
func TestAccessPolicyEvaluation(t *testing.T) {
	t.Parallel()

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

	sshid := deviceSSHID(device)

	everyone := requests.AccessPolicySubject{Type: string(models.PolicySubjectAllMembers)}

	t.Run("a person approved as a member and removed before the login resumes is refused as a non-member", func(t *testing.T) {
		member := newMember(t, compose, "leaver", authorizer.RoleOperator)
		grant(t, compose, &requests.AccessPolicyCreate{Name: "everyone", Subject: everyone, Filter: requests.AccessPolicyFilter{All: true}, Logins: []string{"*"}})

		signer, _ := newSigner(t)
		parked := startLogin(t, compose, sshid, signer)
		prompt := parked.awaitApproval(t)

		confirmation, resp, err := confirmApprovalAs(t.Context(), compose, member.Token, prompt.code, nil)
		require.NoError(t, err)
		require.Equal(t, 200, resp.StatusCode(), resp.String())

		compose.RemoveMember(t, "leaver", ShellHubNamespaceName)

		mark := compose.ServerLogMark(t)

		parked.answer(confirmation.ConfirmationCode)

		require.Error(t, parked.result(t))
		assert.Equal(t, accessDeniedReason, parked.denial())

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

		assert.Empty(t, identitiesHolding(t, compose, ssh.FingerprintSHA256(signer.PublicKey())),
			"leaving the namespace takes the identity the approval enrolled with it")
	})

	t.Run("a source address policy grants only the addresses it lists", func(t *testing.T) {
		member, signer := memberWithKey(t, compose, "sourced", authorizer.RoleOperator)

		ownerSigner, _ := enrollOwnerKey(t, compose)
		session := sessionAfter(t, t.Context(), compose, currentSessions(t, t.Context(), compose), func() {
			requireStraightThrough(t, compose, sshid, ownerSigner)
		})

		client, err := netip.ParseAddr(session.IPAddress)
		require.NoError(t, err, "the session should record the address the gateway saw")

		grant(t, compose, &requests.AccessPolicyCreate{
			Name:     "elsewhere",
			Subject:  userSubject(member.ID),
			Filter:   requests.AccessPolicyFilter{All: true},
			Logins:   []string{"*"},
			SourceIP: []string{unroutableCIDR},
		})

		mark := compose.ServerLogMark(t)
		requireRefusedAtAuth(t, compose, sshid, signer)
		compose.AwaitServerLogLine(t, mark, "reason="+string(models.ReasonNoGrant), "user="+member.ID)

		grant(t, compose, &requests.AccessPolicyCreate{
			Name:     "here",
			Subject:  userSubject(member.ID),
			Filter:   requests.AccessPolicyFilter{All: true},
			Logins:   []string{"*"},
			SourceIP: []string{netip.PrefixFrom(client.Unmap(), client.Unmap().BitLen()).String()},
		})

		requireStraightThrough(t, compose, sshid, signer)
	})

	t.Run("a policy listing logins grants those logins and no other", func(t *testing.T) {
		member, signer := memberWithKey(t, compose, "logins", authorizer.RoleOperator)

		grant(t, compose, &requests.AccessPolicyCreate{
			Name:    "root only",
			Subject: userSubject(member.ID),
			Filter:  requests.AccessPolicyFilter{All: true},
			Logins:  []string{ShellHubAgentUsername},
		})

		requireStraightThrough(t, compose, sshid, signer)

		mark := compose.ServerLogMark(t)
		requireRefusedAtAuth(t, compose, "operator@"+ShellHubNamespaceName+"."+device.Name, signer)
		compose.AwaitServerLogLine(t, mark, "reason="+string(models.ReasonNoGrant), "user="+member.ID, "username=operator")
	})

	t.Run("a tag-filtered policy grants only the devices carrying the tag", func(t *testing.T) {
		member, signer := memberWithKey(t, compose, "tagged", authorizer.RoleOperator)

		createTag(t, compose, "production")

		grant(t, compose, &requests.AccessPolicyCreate{
			Name:    "production",
			Subject: userSubject(member.ID),
			Filter:  requests.AccessPolicyFilter{Tags: []string{"production"}},
			Logins:  []string{"*"},
		})

		mark := compose.ServerLogMark(t)
		requireRefusedAtAuth(t, compose, sshid, signer)
		compose.AwaitServerLogLine(t, mark, "reason="+string(models.ReasonNoGrant), "user="+member.ID)

		tagDevice(t, compose, device.UID, "production")

		requireStraightThrough(t, compose, sshid, signer)
	})

	t.Run("a role policy grants the members holding that role and no other", func(t *testing.T) {
		member, signer := memberWithKey(t, compose, "roled", authorizer.RoleOperator)

		grant(t, compose, &requests.AccessPolicyCreate{
			Name:    "administrators",
			Subject: roleSubject(authorizer.RoleAdministrator),
			Filter:  requests.AccessPolicyFilter{All: true},
			Logins:  []string{"*"},
		})

		mark := compose.ServerLogMark(t)
		requireRefusedAtAuth(t, compose, sshid, signer)
		compose.AwaitServerLogLine(t, mark, "reason="+string(models.ReasonNoGrant), "user="+member.ID)

		grant(t, compose, &requests.AccessPolicyCreate{
			Name:    "operators",
			Subject: roleSubject(authorizer.RoleOperator),
			Filter:  requests.AccessPolicyFilter{All: true},
			Logins:  []string{"*"},
		})

		requireStraightThrough(t, compose, sshid, signer)
	})

	t.Run("a member whose role cannot connect is refused even with policies granting them", func(t *testing.T) {
		member, signer := memberWithKey(t, compose, "demoted", authorizer.RoleOperator)

		personal := grant(t, compose, &requests.AccessPolicyCreate{
			Name:    "demoted",
			Subject: userSubject(member.ID),
			Filter:  requests.AccessPolicyFilter{All: true},
			Logins:  []string{"*"},
		})
		grant(t, compose, &requests.AccessPolicyCreate{Name: "everyone", Subject: everyone, Filter: requests.AccessPolicyFilter{All: true}, Logins: []string{"*"}})

		requireStraightThrough(t, compose, sshid, signer)
		assert.True(t, accessPolicy(t, compose, personal.ID).SubjectMatches)

		reassignRoleAndSignIn(t, compose, member, authorizer.RoleObserver)

		mark := compose.ServerLogMark(t)
		requireRefusedAtAuth(t, compose, sshid, signer)
		compose.AwaitServerLogLine(t, mark, "reason="+string(models.ReasonRoleCannotConnect), "user="+member.ID)

		assert.False(t, accessPolicy(t, compose, personal.ID).SubjectMatches,
			"a policy naming only an observer can never grant, and should say so")
	})

	t.Run("a policy naming a role that cannot connect is refused", func(t *testing.T) {
		resp, err := compose.R(t.Context()).
			SetBody(&requests.AccessPolicyCreate{
				Name:    "observers",
				Subject: roleSubject(authorizer.RoleObserver),
				Filter:  requests.AccessPolicyFilter{All: true},
				Logins:  []string{"*"},
			}).
			Post("/api/access-policies")
		require.NoError(t, err)
		assert.Equal(t, http.StatusBadRequest, resp.StatusCode(), resp.String())
	})

	t.Run("a policy naming only an expired API key is flagged as matching nobody", func(t *testing.T) {
		key, _ := newAPIKeyIdentity(t, compose, "lapsing", false)

		policy := grant(t, compose, &requests.AccessPolicyCreate{
			Name:    "lapsing",
			Subject: apiKeySubject(key.ID),
			Filter:  requests.AccessPolicyFilter{All: true},
			Logins:  []string{"*"},
		})
		assert.True(t, accessPolicy(t, compose, policy.ID).SubjectMatches)

		compose.ExpireAPIKeyIn(t, key.Name, -time.Minute)

		assert.False(t, accessPolicy(t, compose, policy.ID).SubjectMatches)
	})

	t.Run("an every-member policy grants members and not API keys", func(t *testing.T) {
		_, memberSigner := memberWithKey(t, compose, "everyone", authorizer.RoleOperator)
		key, keySigner := newAPIKeyIdentity(t, compose, "automation", false)

		grant(t, compose, &requests.AccessPolicyCreate{Name: "everyone", Subject: everyone, Filter: requests.AccessPolicyFilter{All: true}, Logins: []string{"*"}})

		requireStraightThrough(t, compose, sshid, memberSigner)

		mark := compose.ServerLogMark(t)
		requireRefusedAtAuth(t, compose, sshid, keySigner)
		compose.AwaitServerLogLine(t, mark, "reason="+string(models.ReasonNoGrant), "user="+key.ID)

		grant(t, compose, &requests.AccessPolicyCreate{
			Name:    "automation",
			Subject: apiKeySubject(key.ID),
			Filter:  requests.AccessPolicyFilter{All: true},
			Logins:  []string{"*"},
		})

		requireStraightThrough(t, compose, sshid, keySigner)
	})

	t.Run("a deny policy that cannot be evaluated refuses the login", func(t *testing.T) {
		signer, _ := enrollOwnerKey(t, compose)

		deny := grant(t, compose, &requests.AccessPolicyCreate{
			Name:     "unreadable",
			Subject:  everyone,
			Filter:   requests.AccessPolicyFilter{All: true},
			Logins:   []string{"*"},
			SourceIP: []string{unroutableCIDR},
			Action:   string(models.PolicyActionDeny),
		})

		requireStraightThrough(t, compose, sshid, signer)

		compose.BreakAccessPolicySourceIP(t, deny.ID)

		mark := compose.ServerLogMark(t)
		requireRefusedAtAuth(t, compose, sshid, signer)
		compose.AwaitServerLogLine(t, mark,
			"reason="+string(models.ReasonPolicyUnevaluable), "policy_name=unreadable")
	})
}

func memberWithKey(t *testing.T, compose *environment.DockerCompose, username string, role authorizer.Role) (*models.UserAuthResponse, ssh.Signer) {
	t.Helper()

	member := newMember(t, compose, username, role)

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

	return member, signer
}

func TestAccessPolicyFilterSaysWhichDevices(t *testing.T) {
	t.Parallel()

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

	createTag(t, compose, "filtered")
	t.Cleanup(func() { deleteTag(t, compose, "filtered") })

	refused := []struct {
		description string
		filter      map[string]any
	}{
		{"no filter", nil},
		{"an empty filter", map[string]any{}},
		{"an empty tag list", map[string]any{"tags": []string{}}},
		{"every device and a tag", map[string]any{"all": true, "tags": []string{"filtered"}}},
	}

	body := func(filter map[string]any) map[string]any {
		body := map[string]any{
			"name":    "filtered",
			"subject": map[string]any{"type": string(models.PolicySubjectAllMembers)},
			"logins":  []string{"*"},
		}

		if filter != nil {
			body["filter"] = filter
		}

		return body
	}

	for _, tc := range refused {
		t.Run("create refuses "+tc.description, func(t *testing.T) {
			resp, err := compose.R(t.Context()).SetBody(body(tc.filter)).Post("/api/access-policies")
			require.NoError(t, err)
			assert.Equal(t, 400, resp.StatusCode(), resp.String())
		})
	}

	policy := grant(t, compose, &requests.AccessPolicyCreate{
		Name:    "filtered",
		Subject: requests.AccessPolicySubject{Type: string(models.PolicySubjectAllMembers)},
		Filter:  requests.AccessPolicyFilter{All: true},
		Logins:  []string{"*"},
	})

	assert.True(t, policy.Filter.All, "the response says every device instead of leaving it to an empty tag list")
	assert.Empty(t, policy.Filter.Tags)

	for _, tc := range refused {
		t.Run("update refuses "+tc.description, func(t *testing.T) {
			resp, err := compose.R(t.Context()).SetBody(body(tc.filter)).Put("/api/access-policies/" + policy.ID)
			require.NoError(t, err)
			assert.Equal(t, 400, resp.StatusCode(), resp.String())
		})
	}

	t.Run("update narrows every device to a tag and widens it back", func(t *testing.T) {
		resp, err := compose.R(t.Context()).SetBody(body(map[string]any{"tags": []string{"filtered"}})).Put("/api/access-policies/" + policy.ID)
		require.NoError(t, err)
		require.Equal(t, 200, resp.StatusCode(), resp.String())

		narrowed := accessPolicy(t, compose, policy.ID)
		assert.False(t, narrowed.Filter.All)
		assert.Equal(t, []string{"filtered"}, tagNames(narrowed.Filter.Tags))

		resp, err = compose.R(t.Context()).SetBody(body(map[string]any{"all": true})).Put("/api/access-policies/" + policy.ID)
		require.NoError(t, err)
		require.Equal(t, 200, resp.StatusCode(), resp.String())

		widened := accessPolicy(t, compose, policy.ID)
		assert.True(t, widened.Filter.All)
		assert.Empty(t, widened.Filter.Tags)
	})
}

func createTag(t *testing.T, compose *environment.DockerCompose, name string) {
	t.Helper()

	resp, err := compose.R(t.Context()).SetBody(map[string]string{"name": name}).Post("/api/tags")
	require.NoError(t, err)
	require.Equal(t, 201, resp.StatusCode(), resp.String())
}

func tagDevice(t *testing.T, compose *environment.DockerCompose, uid, tag string) {
	t.Helper()

	attachTag(t, compose, uid, tag)

	t.Cleanup(func() {
		resp, err := compose.R(context.WithoutCancel(t.Context())).Delete("/api/devices/" + uid + "/tags/" + tag)
		require.NoError(t, err)
		require.Equal(t, 204, resp.StatusCode(), resp.String())
	})
}