package main import ( "crypto/sha256" "encoding/hex" "net/http" "slices" "testing" "time" "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/pkg/uuid" "github.com/shellhub-io/shellhub/tests/environment" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestRoutesThatRefuseAPIKeys(t *testing.T) { t.Parallel() compose := environment.New(t, run).Up(t.Context()) t.Cleanup(compose.Down) compose.NewUser(t, ShellHubUsername, ShellHubEmail, ShellHubPassword) compose.NewNamespace(t, ShellHubUsername, ShellHubNamespaceName, ShellHubNamespace) auth := compose.AuthUser(t, ShellHubUsername, ShellHubPassword) compose.JWT(auth.Token) key := compose.CreateAPIKey(t, &requests.CreateAPIKey{ Name: "automation", ExpiresAt: -1, OptRole: authorizer.RoleAdministrator, }) t.Run("the key authenticates on a namespace route", func(t *testing.T) { resp, err := withAPIKey(t, compose, key.Key).Get("/api/devices") require.NoError(t, err) assert.Equal(t, http.StatusOK, resp.StatusCode(), resp.String()) }) cases := []struct { description string method string path string }{ { description: "fetching the user token", method: http.MethodGet, path: "/api/auth/user", }, { description: "switching to a namespace token", method: http.MethodGet, path: "/api/auth/token/" + ShellHubNamespace, }, { description: "listing the namespaces", method: http.MethodGet, path: "/api/namespaces", }, { description: "updating the user", method: http.MethodPatch, path: "/api/users", }, { description: "updating the user through the deprecated route", method: http.MethodPatch, path: "/api/users/" + auth.ID + "/data", }, { description: "changing the password through the deprecated route", method: http.MethodPatch, path: "/api/users/" + auth.ID + "/password", }, { description: "listing the API keys", method: http.MethodGet, path: "/api/namespaces/api-key", }, { description: "creating an API key", method: http.MethodPost, path: "/api/namespaces/api-key", }, { description: "upgrading the namespace", method: http.MethodPost, path: "/api/namespaces/" + ShellHubNamespace + "/upgrade", }, { description: "creating a provisioning key", method: http.MethodPost, path: "/api/namespaces/provisioning-key", }, { description: "listing the provisioning keys", method: http.MethodGet, path: "/api/namespaces/provisioning-key", }, { description: "updating a provisioning key", method: http.MethodPatch, path: "/api/namespaces/provisioning-key/any", }, { description: "revealing a provisioning key", method: http.MethodGet, path: "/api/namespaces/provisioning-key/any/reveal", }, { description: "reading a provisioning key's history", method: http.MethodGet, path: "/api/namespaces/provisioning-key/any/history", }, { description: "generating an invitation link", method: http.MethodPost, path: "/api/namespaces/" + ShellHubNamespace + "/invitations/links", }, { description: "accepting an invitation", method: http.MethodPatch, path: "/api/namespaces/" + ShellHubNamespace + "/invitations/accept", }, { description: "resolving a device login code", method: http.MethodGet, path: "/api/devices/login-code/any", }, { description: "accepting a device pairing", method: http.MethodPost, path: "/api/devices/pairing/any/accept", }, { description: "reading an SSH approval", method: http.MethodGet, path: "/api/ssh-approvals/any", }, { description: "confirming an SSH approval", method: http.MethodPost, path: "/api/ssh-approvals/any/confirm", }, { description: "rejecting an SSH approval", method: http.MethodPost, path: "/api/ssh-approvals/any/reject", }, { description: "renaming an API key", method: http.MethodPatch, path: "/api/namespaces/api-key/" + key.Name, }, { description: "deleting an API key", method: http.MethodDelete, path: "/api/namespaces/api-key/" + key.Name, }, { description: "revoking the user's tokens", method: http.MethodDelete, path: "/api/users/tokens", }, { description: "leaving the namespace", method: http.MethodDelete, path: "/api/namespaces/" + ShellHubNamespace + "/members", }, } for _, tc := range cases { t.Run("refuses "+tc.description, func(t *testing.T) { resp, err := withAPIKey(t, compose, key.Key).Execute(tc.method, tc.path) require.NoError(t, err) assert.Equal(t, http.StatusForbidden, resp.StatusCode(), resp.String()) }) } } // TestEditionRoutesThatRefuseAPIKeys covers the routes the enterprise and cloud editions add that // refuse a namespace API key whatever its role: creating a namespace, MFA, the vault, // re-authenticating a web terminal, and each edition's own account routes, SAML re-authentication // on enterprise and email confirmation, password recovery and account deletion on cloud. Each // edition runs its own stack. The community routes are covered by [TestRoutesThatRefuseAPIKeys]. func TestEditionRoutesThatRefuseAPIKeys(t *testing.T) { t.Parallel() type route struct{ method, path string } shared := []route{ {http.MethodPost, "/api/namespaces"}, {http.MethodGet, "/api/user/mfa/generate"}, {http.MethodPost, "/api/user/mfa/auth"}, {http.MethodPut, "/api/user/mfa/enable"}, {http.MethodPut, "/api/user/mfa/disable"}, {http.MethodPut, "/api/user/mfa/recovery/disable"}, {http.MethodPost, "/api/user/mfa/recover"}, {http.MethodPost, "/api/user/mfa/reset"}, {http.MethodPut, "/api/user/mfa/reset/any"}, {http.MethodPost, "/api/web-terminal/reauth"}, {http.MethodGet, "/api/vault"}, {http.MethodPut, "/api/vault/meta"}, {http.MethodPut, "/api/vault/data"}, {http.MethodPut, "/api/vault/settings"}, {http.MethodDelete, "/api/vault"}, } editions := []struct { edition environment.Edition own []route }{ { edition: environment.EditionEnterprise, own: []route{{http.MethodGet, "/api/user/saml/reauth"}}, }, { edition: environment.EditionCloud, own: []route{ {http.MethodPost, "/api/user/resend_email"}, {http.MethodPost, "/api/user/confirm_account"}, {http.MethodPost, "/api/user/recover_password"}, {http.MethodPost, "/api/user/any/update_password"}, {http.MethodDelete, "/api/user"}, }, }, } for _, tc := range editions { t.Run(string(tc.edition), func(t *testing.T) { t.Parallel() compose := environment.New(t, run).WithEdition(tc.edition).Up(t.Context()) t.Cleanup(compose.Down) compose.NewUser(t, ShellHubUsername, ShellHubEmail, ShellHubPassword) compose.NewNamespace(t, ShellHubUsername, ShellHubNamespaceName, ShellHubNamespace) compose.JWT(compose.AuthUser(t, ShellHubUsername, ShellHubPassword).Token) key := compose.CreateAPIKey(t, &requests.CreateAPIKey{ Name: "automation", ExpiresAt: -1, OptRole: authorizer.RoleAdministrator, }) requireAnswer(t, withAPIKey(t, compose, key.Key), http.MethodGet, "/api/devices", http.StatusOK) for _, r := range append(slices.Clone(shared), tc.own...) { t.Run("refuses "+r.method+" "+r.path, func(t *testing.T) { requireAnswer(t, withAPIKey(t, compose, key.Key), r.method, r.path, http.StatusForbidden) }) } }) } } // TestNamespaceAPIKeyAuthentication covers what a namespace API key can do once minted: manage the // namespace's tags, act only within its role, keep working when it never expires, and stop working // once its expiry passes. A role change and a deletion take effect on the key's next request, past // the cached copy authentication keeps, and no two keys of a namespace share a name. The routes that // refuse a key whatever its role are covered by [TestRoutesThatRefuseAPIKeys], and an instance key // refused on a namespace route by [TestEnterpriseAdmin]. func TestNamespaceAPIKeyAuthentication(t *testing.T) { t.Parallel() compose := environment.New(t, run).Up(t.Context()) t.Cleanup(compose.Down) compose.NewUser(t, ShellHubUsername, ShellHubEmail, ShellHubPassword) compose.NewNamespace(t, ShellHubUsername, ShellHubNamespaceName, ShellHubNamespace) compose.JWT(compose.AuthUser(t, ShellHubUsername, ShellHubPassword).Token) useManualProvisioningKey(t, compose) _, device := startAcceptedAgent(t, t.Context(), compose) deviceTags := func(t *testing.T) []string { t.Helper() current, resp, err := compose.GetDevice(t.Context(), device.UID) require.NoError(t, err) require.Equal(t, http.StatusOK, resp.StatusCode(), resp.String()) return tagNames(current.Tags) } namespaceTags := func(t *testing.T, req *resty.Request) []string { t.Helper() tags := []models.Tag{} resp, err := req.SetResult(&tags).Get("/api/tags") require.NoError(t, err) require.Equal(t, http.StatusOK, resp.StatusCode(), resp.String()) return tagNames(tags) } t.Run("a key creates, lists, attaches, renames, detaches and deletes tags", func(t *testing.T) { key := compose.CreateAPIKey(t, &requests.CreateAPIKey{Name: "tagger", ExpiresAt: -1, OptRole: authorizer.RoleOperator}) resp, err := withAPIKey(t, compose, key.Key).SetBody(map[string]string{"name": "staging"}).Post("/api/tags") require.NoError(t, err) require.Equal(t, http.StatusCreated, resp.StatusCode(), resp.String()) assert.Contains(t, namespaceTags(t, withAPIKey(t, compose, key.Key)), "staging") resp, err = withAPIKey(t, compose, key.Key).Post("/api/devices/" + device.UID + "/tags/staging") require.NoError(t, err) require.Equal(t, http.StatusNoContent, resp.StatusCode(), resp.String()) assert.Equal(t, []string{"staging"}, deviceTags(t)) renamed := new(models.Tag) resp, err = withAPIKey(t, compose, key.Key). SetBody(map[string]string{"name": "production"}). SetResult(renamed). Patch("/api/tags/staging") require.NoError(t, err) require.Equal(t, http.StatusOK, resp.StatusCode(), resp.String()) assert.Equal(t, "production", renamed.Name) assert.Equal(t, ShellHubNamespace, renamed.TenantID) assert.True(t, renamed.UpdatedAt.After(renamed.CreatedAt)) tagsAfterRename := namespaceTags(t, withAPIKey(t, compose, key.Key)) assert.Contains(t, tagsAfterRename, "production") assert.NotContains(t, tagsAfterRename, "staging") assert.Equal(t, []string{"production"}, deviceTags(t)) resp, err = withAPIKey(t, compose, key.Key).Delete("/api/devices/" + device.UID + "/tags/production") require.NoError(t, err) require.Equal(t, http.StatusNoContent, resp.StatusCode(), resp.String()) assert.Empty(t, deviceTags(t)) resp, err = withAPIKey(t, compose, key.Key).Delete("/api/tags/production") require.NoError(t, err) require.Equal(t, http.StatusNoContent, resp.StatusCode(), resp.String()) assert.NotContains(t, namespaceTags(t, compose.R(t.Context())), "production") }) t.Run("a key acts only within its role", func(t *testing.T) { listDevices := func(req *resty.Request) (*resty.Response, error) { return req.Get("/api/devices") } createTag := func(req *resty.Request) (*resty.Response, error) { return req.SetBody(map[string]string{"name": "role" + uuid.Generate()[:8]}).Post("/api/tags") } listAccessPolicies := func(req *resty.Request) (*resty.Response, error) { return req.Get("/api/access-policies") } cases := []struct { role authorizer.Role devices, tags, policies int }{ {role: authorizer.RoleObserver, devices: http.StatusOK, tags: http.StatusForbidden, policies: http.StatusForbidden}, {role: authorizer.RoleOperator, devices: http.StatusOK, tags: http.StatusCreated, policies: http.StatusForbidden}, {role: authorizer.RoleAdministrator, devices: http.StatusOK, tags: http.StatusCreated, policies: http.StatusOK}, } for _, tc := range cases { t.Run(tc.role.String(), func(t *testing.T) { key := compose.CreateAPIKey(t, &requests.CreateAPIKey{Name: tc.role.String(), ExpiresAt: -1, OptRole: tc.role}) require.Equal(t, tc.role, key.Role) for _, check := range []struct { request func(*resty.Request) (*resty.Response, error) want int }{ {request: listDevices, want: tc.devices}, {request: createTag, want: tc.tags}, {request: listAccessPolicies, want: tc.policies}, } { resp, err := check.request(withAPIKey(t, compose, key.Key)) require.NoError(t, err) assert.Equal(t, check.want, resp.StatusCode(), "%s %s: %s", resp.Request.Method, resp.Request.URL, resp.String()) } }) } }) t.Run("a key that never expires is stored without an expiry and authenticates", func(t *testing.T) { key := compose.CreateAPIKey(t, &requests.CreateAPIKey{Name: "forever", ExpiresAt: -1}) require.Equal(t, int64(-1), key.ExpiresIn) assert.Equal(t, int64(-1), listedAPIKey(t, compose, "forever").ExpiresIn, "-1 is the marker for a key that never expires, not a time") resp, err := withAPIKey(t, compose, key.Key).Get("/api/devices") require.NoError(t, err) assert.Equal(t, http.StatusOK, resp.StatusCode(), resp.String()) }) t.Run("a deleted key stops authenticating at once and leaves the cache", func(t *testing.T) { key := compose.CreateAPIKey(t, &requests.CreateAPIKey{Name: "deleted", ExpiresAt: -1}) requireAnswer(t, withAPIKey(t, compose, key.Key), http.MethodGet, "/api/devices", http.StatusOK) require.True(t, compose.HoldsCacheEntry(t, apiKeyCacheKey(key.Key)), "authentication did not cache the key") requireAnswer(t, compose.R(t.Context()), http.MethodDelete, "/api/namespaces/api-key/"+key.Name, http.StatusOK) assert.False(t, compose.HoldsCacheEntry(t, apiKeyCacheKey(key.Key)), "the deletion left the key cached") requireAnswer(t, withAPIKey(t, compose, key.Key), http.MethodGet, "/api/devices", http.StatusUnauthorized) }) t.Run("a role change takes effect on the key's next request", func(t *testing.T) { key := compose.CreateAPIKey(t, &requests.CreateAPIKey{Name: "promoted", ExpiresAt: -1, OptRole: authorizer.RoleObserver}) changeRole := func(t *testing.T, role authorizer.Role) { t.Helper() resp, err := compose.R(t.Context()). SetBody(map[string]string{"role": role.String()}). Patch("/api/namespaces/api-key/" + key.Name) require.NoError(t, err) require.Equal(t, http.StatusOK, resp.StatusCode(), resp.String()) assert.False(t, compose.HoldsCacheEntry(t, apiKeyCacheKey(key.Key)), "the role change left the key cached") } requireAnswer(t, withAPIKey(t, compose, key.Key), http.MethodGet, "/api/access-policies", http.StatusForbidden) require.True(t, compose.HoldsCacheEntry(t, apiKeyCacheKey(key.Key)), "authentication did not cache the key") changeRole(t, authorizer.RoleAdministrator) requireAnswer(t, withAPIKey(t, compose, key.Key), http.MethodGet, "/api/access-policies", http.StatusOK) changeRole(t, authorizer.RoleObserver) requireAnswer(t, withAPIKey(t, compose, key.Key), http.MethodGet, "/api/access-policies", http.StatusForbidden) assert.Equal(t, authorizer.RoleObserver, listedAPIKey(t, compose, key.Name).Role) }) t.Run("a name another key holds is refused on create and on rename", func(t *testing.T) { compose.CreateAPIKey(t, &requests.CreateAPIKey{Name: "taken", ExpiresAt: -1}) other := compose.CreateAPIKey(t, &requests.CreateAPIKey{Name: "other", ExpiresAt: -1}) resp, err := compose.R(t.Context()). SetBody(&requests.CreateAPIKey{Name: "taken", ExpiresAt: -1}). Post("/api/namespaces/api-key") require.NoError(t, err) assert.Equal(t, http.StatusConflict, resp.StatusCode(), resp.String()) resp, err = compose.R(t.Context()). SetBody(map[string]string{"name": "taken"}). Patch("/api/namespaces/api-key/" + other.Name) require.NoError(t, err) assert.Equal(t, http.StatusConflict, resp.StatusCode(), resp.String()) listedAPIKey(t, compose, other.Name) requireAnswer(t, withAPIKey(t, compose, other.Key), http.MethodGet, "/api/devices", http.StatusOK) }) t.Run("a key is refused once its expiry passes", func(t *testing.T) { const ttl = 3 * time.Second key := compose.CreateAPIKey(t, &requests.CreateAPIKey{Name: "expiring", ExpiresAt: 30}) compose.ExpireAPIKeyIn(t, key.Name, ttl) resp, err := withAPIKey(t, compose, key.Key).Get("/api/devices") require.NoError(t, err) require.Equal(t, http.StatusOK, resp.StatusCode(), resp.String()) require.EventuallyWithT(t, func(tt *assert.CollectT) { resp, err := withAPIKey(t, compose, key.Key).Get("/api/devices") if !assert.NoError(tt, err) { return } assert.Equal(tt, http.StatusUnauthorized, resp.StatusCode(), resp.String()) }, 3*ttl, 250*time.Millisecond) }) } func TestAPIKeyRoleCap(t *testing.T) { t.Parallel() compose := environment.New(t, run).Up(t.Context()) t.Cleanup(compose.Down) compose.NewUser(t, ShellHubUsername, ShellHubEmail, ShellHubPassword) compose.NewNamespace(t, ShellHubUsername, ShellHubNamespaceName, ShellHubNamespace) compose.JWT(compose.AuthUser(t, ShellHubUsername, ShellHubPassword).Token) requestKey := func(t *testing.T, req *resty.Request, name string, role authorizer.Role) *resty.Response { t.Helper() resp, err := req.SetBody(&requests.CreateAPIKey{Name: name, ExpiresAt: -1, OptRole: role}).Post("/api/namespaces/api-key") require.NoError(t, err) return resp } t.Run("an owner's key created without a role is an administrator key", func(t *testing.T) { key := compose.CreateAPIKey(t, &requests.CreateAPIKey{Name: "defaulted", ExpiresAt: -1}) assert.Equal(t, authorizer.RoleAdministrator, key.Role) assert.Equal(t, authorizer.RoleAdministrator, listedAPIKey(t, compose, key.Name).Role) resp, err := withAPIKey(t, compose, key.Key).Delete("/api/namespaces/" + ShellHubNamespace) require.NoError(t, err) assert.Equal(t, http.StatusForbidden, resp.StatusCode(), resp.String()) }) t.Run("no key is created as owner", func(t *testing.T) { resp := requestKey(t, compose.R(t.Context()), "owner", authorizer.RoleOwner) assert.Equal(t, http.StatusForbidden, resp.StatusCode(), resp.String()) }) t.Run("no key is raised to owner", func(t *testing.T) { key := compose.CreateAPIKey(t, &requests.CreateAPIKey{Name: "raised", ExpiresAt: -1, OptRole: authorizer.RoleObserver}) resp, err := compose.R(t.Context()). SetBody(map[string]string{"role": authorizer.RoleOwner.String()}). Patch("/api/namespaces/api-key/" + key.Name) require.NoError(t, err) assert.Equal(t, http.StatusForbidden, resp.StatusCode(), resp.String()) assert.Equal(t, authorizer.RoleObserver, listedAPIKey(t, compose, key.Name).Role) }) t.Run("an administrator raises a key no higher than administrator", func(t *testing.T) { administrator := newMember(t, compose, "raiser", authorizer.RoleAdministrator) key := compose.CreateAPIKey(t, &requests.CreateAPIKey{Name: "raised-by-admin", ExpiresAt: -1, OptRole: authorizer.RoleObserver}) raise := func(role authorizer.Role) *resty.Response { resp, err := asBearer(t, compose, administrator.Token). SetBody(map[string]string{"role": role.String()}). Patch("/api/namespaces/api-key/" + key.Name) require.NoError(t, err) return resp } resp := raise(authorizer.RoleOwner) assert.Equal(t, http.StatusForbidden, resp.StatusCode(), resp.String()) assert.Equal(t, authorizer.RoleObserver, listedAPIKey(t, compose, key.Name).Role) resp = raise(authorizer.RoleAdministrator) assert.Equal(t, http.StatusOK, resp.StatusCode(), resp.String()) assert.Equal(t, authorizer.RoleAdministrator, listedAPIKey(t, compose, key.Name).Role) }) t.Run("an operator creates no key", func(t *testing.T) { operator := newMember(t, compose, "operator", authorizer.RoleOperator) resp := requestKey(t, asBearer(t, compose, operator.Token), "operator", "") assert.Equal(t, http.StatusForbidden, resp.StatusCode(), resp.String()) }) t.Run("a key keeps its role after its creator is demoted", func(t *testing.T) { creator := newMember(t, compose, "demoted", authorizer.RoleAdministrator) key := new(responses.CreateAPIKey) resp := requestKey(t, asBearer(t, compose, creator.Token).SetResult(key), "demoted", "") require.Equal(t, http.StatusOK, resp.StatusCode(), resp.String()) require.Equal(t, authorizer.RoleAdministrator, key.Role) resp, err := compose.R(t.Context()). SetBody(map[string]string{"role": authorizer.RoleOperator.String()}). Patch("/api/namespaces/" + ShellHubNamespace + "/members/" + creator.ID) require.NoError(t, err) require.Equal(t, http.StatusOK, resp.StatusCode(), resp.String()) assert.Equal(t, authorizer.RoleAdministrator, listedAPIKey(t, compose, key.Name).Role) resp, err = withAPIKey(t, compose, key.Key).Get("/api/access-policies") require.NoError(t, err) assert.Equal(t, http.StatusOK, resp.StatusCode(), resp.String()) }) } func listedAPIKey(t *testing.T, compose *environment.DockerCompose, name string) models.APIKey { t.Helper() keys := []models.APIKey{} resp, err := compose.R(t.Context()).SetResult(&keys).Get("/api/namespaces/api-key") require.NoError(t, err) require.Equal(t, http.StatusOK, resp.StatusCode(), resp.String()) index := slices.IndexFunc(keys, func(k models.APIKey) bool { return k.Name == name }) require.GreaterOrEqual(t, index, 0, "the key %s is not listed", name) return keys[index] } func apiKeyCacheKey(plaintext string) string { digest := sha256.Sum256([]byte(plaintext)) return "api-key/unique-digest={" + hex.EncodeToString(digest[:]) + "}" } func withAPIKey(t *testing.T, compose *environment.DockerCompose, plaintext string) *resty.Request { t.Helper() return compose.Anonymous(t.Context()).SetHeader("X-API-Key", plaintext) }