package main import ( "context" "net/http" "testing" "github.com/go-resty/resty/v2" "github.com/shellhub-io/shellhub/pkg/api/authorizer" "github.com/shellhub-io/shellhub/pkg/api/requests" "github.com/shellhub-io/shellhub/pkg/api/responses" "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" ) // TestTagsSelectPublicKeyDevices covers a tag as the device selector of a legacy public key. The key // holds the tag itself, not its name, so a rename keeps the key reaching the devices carrying it. // A filter left without its only tag would select every device, so the tag cannot be deleted // while a key's filter holds it. func TestTagsSelectPublicKeyDevices(t *testing.T) { t.Parallel() ctx := context.Background() compose := newSSHEnvironment(t, ctx, legacyNamespace) _, tagged := startAcceptedAgent(t, ctx, compose) _, untagged := startAcceptedAgent(t, ctx, compose) t.Run("a renamed tag keeps selecting the devices carrying it", func(t *testing.T) { createTag(t, compose, "keybefore") t.Cleanup(func() { deleteTag(t, compose, "keyafter") }) attachTag(t, compose, tagged.UID, "keybefore") signer := registerPublicKey(t, compose, ".*", requests.PublicKeyFilter{Tags: []string{"keybefore"}}) renameTag(t, compose, "keybefore", "keyafter") assert.Equal(t, []string{"keyafter"}, tagNames(publicKey(t, compose, signer).Filter.Tags)) requireKeyLogsIn(t, compose, deviceSSHID(tagged), signer) requireKeyRefused(t, compose, deviceSSHID(untagged), signer, errPublicKeyNotEvaluated) }) t.Run("a tag a key's filter holds is not deleted", func(t *testing.T) { createTag(t, compose, "keyheld") t.Cleanup(func() { deleteTag(t, compose, "keyheld") }) attachTag(t, compose, tagged.UID, "keyheld") signer := registerPublicKey(t, compose, ".*", requests.PublicKeyFilter{Tags: []string{"keyheld"}}) requireTagInUse(t, compose, "keyheld", `public key "`+t.Name()+`"`) assert.Contains(t, listedTags(t, compose.R(t.Context())), "keyheld") assert.Equal(t, []string{"keyheld"}, tagNames(publicKey(t, compose, signer).Filter.Tags)) requireDeviceTags(t, compose, tagged.UID, "keyheld") requireKeyLogsIn(t, compose, deviceSSHID(tagged), signer) requireKeyRefused(t, compose, deviceSSHID(untagged), signer, errPublicKeyNotEvaluated) }) } // TestTagsSelectAccessPolicyDevices covers a tag as the device filter of an access policy. The // policy holds the tag itself, not its name, so a rename keeps it granting the devices carrying the // tag. A deny left without a tag it holds would block fewer devices and so widen access, so the // tag cannot be deleted while a policy's filter holds it. Each case logs in as a member only its // own policy grants, so the owner's starter policy never decides it. func TestTagsSelectAccessPolicyDevices(t *testing.T) { t.Parallel() ctx := context.Background() compose := newSSHEnvironment(t, ctx, newNamespace) _, tagged := startAcceptedAgent(t, ctx, compose) _, untagged := startAcceptedAgent(t, ctx, compose) t.Run("a renamed tag keeps granting the devices carrying it", func(t *testing.T) { member, signer := memberWithKey(t, compose, "renamed", authorizer.RoleOperator) createTag(t, compose, "policybefore") t.Cleanup(func() { deleteTag(t, compose, "policyafter") }) attachTag(t, compose, tagged.UID, "policybefore") policy := grant(t, compose, &requests.AccessPolicyCreate{ Name: "renamed", Subject: userSubject(member.ID), Filter: requests.AccessPolicyFilter{Tags: []string{"policybefore"}}, Logins: []string{"*"}, }) renameTag(t, compose, "policybefore", "policyafter") assert.Equal(t, []string{"policyafter"}, tagNames(accessPolicy(t, compose, policy.ID).Filter.Tags)) requireStraightThrough(t, compose, deviceSSHID(tagged), signer) requireRefusedAtAuth(t, compose, deviceSSHID(untagged), signer) }) t.Run("a tag a policy's filter holds is not deleted", func(t *testing.T) { member, signer := memberWithKey(t, compose, "held", authorizer.RoleOperator) createTag(t, compose, "policyheld") t.Cleanup(func() { deleteTag(t, compose, "policyheld") }) attachTag(t, compose, tagged.UID, "policyheld") policy := grant(t, compose, &requests.AccessPolicyCreate{ Name: "held", Subject: userSubject(member.ID), Filter: requests.AccessPolicyFilter{Tags: []string{"policyheld"}}, Logins: []string{"*"}, }) requireTagInUse(t, compose, "policyheld", `access policy "held"`) assert.Contains(t, listedTags(t, compose.R(t.Context())), "policyheld") assert.Equal(t, []string{"policyheld"}, tagNames(accessPolicy(t, compose, policy.ID).Filter.Tags)) requireDeviceTags(t, compose, tagged.UID, "policyheld") requireStraightThrough(t, compose, deviceSSHID(tagged), signer) requireRefusedAtAuth(t, compose, deviceSSHID(untagged), signer) }) } // TestEnterpriseTagsSelectFirewallRuleDevices covers a tag as the device filter of a firewall rule. // A rule left without its only tag matches every device, so an allow rule would let through what a // lower-priority deny blocks; the tag cannot be deleted while a rule's filter holds it. func TestEnterpriseTagsSelectFirewallRuleDevices(t *testing.T) { t.Parallel() ctx := context.Background() compose := newEnterpriseEnvironment(t, ctx, environment.New(t, run)) signer := registerDeviceKey(t, ctx, compose) _, device := startAcceptedAgent(t, ctx, compose) sshid := deviceSSHID(device) t.Run("a tag a firewall rule's filter holds is not deleted", func(t *testing.T) { createTag(t, compose, "ruleheld") t.Cleanup(func() { deleteTag(t, compose, "ruleheld") }) addFirewallRule(t, compose, deny(100, matching(anyValue, anyValue, anyDevice))) rule := compose.CreateFirewallRule(t, allow(1, matching(anyValue, anyValue, environment.FirewallRuleFilter{Tags: []string{"ruleheld"}}))) t.Cleanup(func() { compose.DeleteFirewallRule(t, rule) }) requireAccessDenied(t, compose, sshid, signer, firewallBlockedLog, errFirewallRuleBlock) requireTagInUse(t, compose, "ruleheld", `firewall rule "`+rule+`"`) assert.Contains(t, listedTags(t, compose.R(t.Context())), "ruleheld") requireAccessDenied(t, compose, sshid, signer, firewallBlockedLog, errFirewallRuleBlock) }) } // TestTagDeleteDetachesItEverywhere covers a tag no filter holds: deleting it takes it off every // device carrying it, whatever the device's status, and leaves the devices' other tags in place. func TestTagDeleteDetachesItEverywhere(t *testing.T) { t.Parallel() compose := newSSHEnvironment(t, t.Context(), legacyNamespace) accepted := enrollDevice(t, compose, "detachaccepted", "02:00:00:00:11:01") compose.UpdateDeviceStatus(t, accepted, environment.DeviceActionAccept) pending := enrollDevice(t, compose, "detachpending", "02:00:00:00:11:02") createTag(t, compose, "detached") createTag(t, compose, "kept") for _, uid := range []string{accepted, pending} { attachTag(t, compose, uid, "detached") attachTag(t, compose, uid, "kept") } deleteTag(t, compose, "detached") assert.NotContains(t, listedTags(t, compose.R(t.Context())), "detached") requireDeviceTags(t, compose, accepted, "kept") requireDeviceTags(t, compose, pending, "kept") } // TestTagNamesAreUniqueInNamespace covers a tag name already taken in the namespace: creating a tag // or renaming another to it answers 409 naming the field, and leaves both tags as they were. func TestTagNamesAreUniqueInNamespace(t *testing.T) { t.Parallel() compose := newSSHEnvironment(t, t.Context(), legacyNamespace) createTag(t, compose, "taken") createTag(t, compose, "other") attempts := []struct { name string method string path string }{ {"creating a tag with a taken name answers 409", http.MethodPost, "/api/tags"}, {"renaming a tag to a taken name answers 409", http.MethodPatch, "/api/tags/other"}, } for _, attempt := range attempts { t.Run(attempt.name, func(t *testing.T) { refusal := new(responses.Error) resp, err := compose.R(t.Context()).SetBody(map[string]string{"name": "taken"}).SetError(refusal).Execute(attempt.method, attempt.path) require.NoError(t, err) require.Equal(t, http.StatusConflict, resp.StatusCode(), resp.String()) assert.Equal(t, map[string]string{"name": "duplicated"}, refusal.Fields) assert.ElementsMatch(t, []string{"taken", "other"}, listedTags(t, compose.R(t.Context()))) }) } } // TestTagsAppliedByProvisioningKey covers a tag a provisioning key applies to the devices it enrolls. // The key holds the tag itself, not its name, so a rename carries over to the devices it enrolls // next. A key left without a tag would silently stop applying it, so the tag cannot be deleted while // a key holds it, unless the key is revoked and so enrolls nothing more. func TestTagsAppliedByProvisioningKey(t *testing.T) { t.Parallel() compose := newSSHEnvironment(t, t.Context(), newNamespace) newKey := func(t *testing.T, name, tag string) string { t.Helper() return compose.CreateProvisioningKey(t, &requests.CreateProvisioningKey{ Name: name, Mode: string(models.ProvisioningKeyModeAutomatic), Tags: []string{tag}, }).Key } t.Run("a renamed tag keeps being applied under its new name", func(t *testing.T) { key := newKey(t, "renamedtag", "keybefore") before := enroll(t, compose, newKeyedDeviceAuthRequest(t, key, "renamedbefore", "02:00:00:00:13:01")) require.Equal(t, []string{"keybefore"}, tagNames(before.Tags)) renameTag(t, compose, "keybefore", "keyafter") after := enroll(t, compose, newKeyedDeviceAuthRequest(t, key, "renamedafter", "02:00:00:00:13:02")) assert.Equal(t, []string{"keyafter"}, compose.ProvisioningKey(t, "renamedtag").Tags) requireDeviceTags(t, compose, before.UID, "keyafter") assert.Equal(t, []string{"keyafter"}, tagNames(after.Tags)) assert.NotContains(t, listedTags(t, compose.R(t.Context())), "keybefore") }) t.Run("a tag a provisioning key applies is not deleted", func(t *testing.T) { key := newKey(t, "heldtag", "keyheld") before := enroll(t, compose, newKeyedDeviceAuthRequest(t, key, "heldbefore", "02:00:00:00:13:03")) require.Equal(t, []string{"keyheld"}, tagNames(before.Tags)) requireTagInUse(t, compose, "keyheld", `provisioning key "heldtag"`) after := enroll(t, compose, newKeyedDeviceAuthRequest(t, key, "heldafter", "02:00:00:00:13:04")) assert.Contains(t, listedTags(t, compose.R(t.Context())), "keyheld") assert.Equal(t, []string{"keyheld"}, compose.ProvisioningKey(t, "heldtag").Tags) requireDeviceTags(t, compose, before.UID, "keyheld") assert.Equal(t, []string{"keyheld"}, tagNames(after.Tags)) }) t.Run("a tag only a revoked provisioning key holds is deleted", func(t *testing.T) { key := newKey(t, "revokedtag", "keyrevoked") before := enroll(t, compose, newKeyedDeviceAuthRequest(t, key, "revokedbefore", "02:00:00:00:13:05")) require.Equal(t, []string{"keyrevoked"}, tagNames(before.Tags)) compose.UpdateProvisioningKey(t, "revokedtag", map[string]any{"revoked": true}) deleteTag(t, compose, "keyrevoked") assert.NotContains(t, listedTags(t, compose.R(t.Context())), "keyrevoked") assert.Empty(t, compose.ProvisioningKey(t, "revokedtag").Tags) requireDeviceTags(t, compose, before.UID) }) } // TestTagsAreNamespaceScoped covers two namespaces using the same tag name. Each namespace sees, // attaches, renames and deletes only its own tag, so a change in one leaves the other untouched. func TestTagsAreNamespaceScoped(t *testing.T) { t.Parallel() const ( neighbourUsername = "neighbour" neighbourTenant = "11111111-1111-4111-1111-111111111111" ) compose := newSSHEnvironment(t, t.Context(), legacyNamespace) compose.NewUser(t, neighbourUsername, "neighbour@ossystems.com.br", ShellHubPassword) compose.NewLegacyNamespace(t, neighbourUsername, "neighbourhood", neighbourTenant) neighbour := compose.AuthUser(t, neighbourUsername, ShellHubPassword) asNeighbour := func(ctx context.Context) *resty.Request { return compose.Anonymous(ctx).SetAuthToken(neighbour.Token) } own := enrollDevice(t, compose, "owndevice", "02:00:00:00:12:01") createTag(t, compose, "shared") attachTag(t, compose, own, "shared") neighbourDevice := newDeviceAuthRequest(t, "neighbourdevice", "02:00:00:00:12:02") neighbourDevice.TenantID = neighbourTenant theirs := authDevice(t, compose, neighbourDevice).UID t.Run("another namespace does not list the tag", func(t *testing.T) { assert.NotContains(t, listedTags(t, asNeighbour(t.Context())), "shared") }) t.Run("another namespace cannot attach, rename or delete the tag", func(t *testing.T) { attempts := []struct { method string path string body any }{ {method: http.MethodPost, path: "/api/devices/" + theirs + "/tags/shared"}, {method: http.MethodPatch, path: "/api/tags/shared", body: map[string]string{"name": "taken"}}, {method: http.MethodDelete, path: "/api/tags/shared"}, } for _, attempt := range attempts { req := asNeighbour(t.Context()) if attempt.body != nil { req = req.SetBody(attempt.body) } resp, err := req.Execute(attempt.method, attempt.path) require.NoError(t, err) assert.Equal(t, http.StatusNotFound, resp.StatusCode(), "%s %s: %s", attempt.method, attempt.path, resp.String()) } assert.Contains(t, listedTags(t, compose.R(t.Context())), "shared") requireDeviceTags(t, compose, own, "shared") }) t.Run("another namespace keeps its own tag of that name through the first one's rename and delete", func(t *testing.T) { resp, err := asNeighbour(t.Context()).SetBody(map[string]string{"name": "shared"}).Post("/api/tags") require.NoError(t, err) require.Equal(t, http.StatusCreated, resp.StatusCode(), resp.String()) resp, err = asNeighbour(t.Context()).Post("/api/devices/" + theirs + "/tags/shared") require.NoError(t, err) require.Equal(t, http.StatusNoContent, resp.StatusCode(), resp.String()) renameTag(t, compose, "shared", "renamed") deleteTag(t, compose, "renamed") assert.Equal(t, []string{"shared"}, listedTags(t, asNeighbour(t.Context()))) device := new(models.Device) resp, err = asNeighbour(t.Context()).SetResult(device).Get("/api/devices/" + theirs) require.NoError(t, err) require.Equal(t, http.StatusOK, resp.StatusCode(), resp.String()) assert.Equal(t, []string{"shared"}, tagNames(device.Tags)) }) } func attachTag(t *testing.T, compose *environment.DockerCompose, uid, tag string) { t.Helper() resp, err := compose.R(t.Context()).Post("/api/devices/" + uid + "/tags/" + tag) require.NoError(t, err) require.Equal(t, http.StatusNoContent, resp.StatusCode(), resp.String()) } func renameTag(t *testing.T, compose *environment.DockerCompose, from, to string) { t.Helper() resp, err := compose.R(t.Context()).SetBody(map[string]string{"name": to}).Patch("/api/tags/" + from) require.NoError(t, err) require.Equal(t, http.StatusOK, resp.StatusCode(), resp.String()) } func deleteTag(t *testing.T, compose *environment.DockerCompose, name string) { t.Helper() resp, err := compose.R(context.WithoutCancel(t.Context())).Delete("/api/tags/" + name) require.NoError(t, err) require.Equal(t, http.StatusNoContent, resp.StatusCode(), resp.String()) } func requireTagInUse(t *testing.T, compose *environment.DockerCompose, name, holder string) { t.Helper() refusal := new(responses.Error) resp, err := compose.R(t.Context()).SetError(refusal).Delete("/api/tags/" + name) require.NoError(t, err) require.Equal(t, http.StatusConflict, resp.StatusCode(), resp.String()) assert.Equal(t, map[string]string{"name": holder}, refusal.Fields) } func listedTags(t *testing.T, req *resty.Request) []string { t.Helper() tags := []models.Tag{} resp, err := req.SetQueryParam("per_page", "100").SetResult(&tags).Get("/api/tags") require.NoError(t, err) require.Equal(t, http.StatusOK, resp.StatusCode(), resp.String()) return tagNames(tags) } func requireDeviceTags(t *testing.T, compose *environment.DockerCompose, uid string, tags ...string) { t.Helper() device, resp, err := compose.GetDevice(t.Context(), uid) require.NoError(t, err) require.Equal(t, http.StatusOK, resp.StatusCode(), resp.String()) assert.ElementsMatch(t, tags, tagNames(device.Tags)) } func publicKey(t *testing.T, compose *environment.DockerCompose, signer ssh.Signer) models.PublicKey { t.Helper() keys := []models.PublicKey{} resp, err := compose.R(t.Context()). SetQueryParam("filter", propertyFilter(t, "fingerprint", "eq", ssh.FingerprintLegacyMD5(signer.PublicKey()))). SetResult(&keys). Get("/api/sshkeys/public-keys") require.NoError(t, err) require.Equal(t, http.StatusOK, resp.StatusCode(), resp.String()) require.Len(t, keys, 1) return keys[0] } func accessPolicy(t *testing.T, compose *environment.DockerCompose, id string) models.AccessPolicy { t.Helper() policy := models.AccessPolicy{} resp, err := compose.R(t.Context()).SetResult(&policy).Get("/api/access-policies/" + id) require.NoError(t, err) require.Equal(t, http.StatusOK, resp.StatusCode(), resp.String()) return policy }