tests/api_key_test.go
Rama master · réplica de lectura del repositorio original.
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)
}