Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions tests/api_key_ssh_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package main

import (
"context"
"net/http"
"testing"
"time"

"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"
)

// TestAPIKeySSHAccess covers what decides an API key's SSH login in the identity mode: no policy
// naming the key lets it in, a deny naming it beats the allow that would, its identity works until
// its expiry, and deleting the key takes its identities with it. That an every-member policy does
// not grant a key is covered by [TestAccessPolicyEvaluation], single-use identities by
// [TestSSHIdentityConnection], and that a key is never asked to re-authenticate by
// [TestAccessPolicyReauth].
func TestAPIKeySSHAccess(t *testing.T) {
ctx := context.Background()
compose := newSSHEnvironment(t, ctx, models.SSHAccessModeIdentity)
_, device := startAcceptedAgent(t, ctx, compose)

sshid := deviceSSHID(device)

t.Run("a key no policy names is refused", func(t *testing.T) {
key, signer := newAPIKeyIdentity(t, compose, "ungranted", false)

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

t.Run("a deny policy naming the key beats the allow that names it", func(t *testing.T) {
key, signer := newAPIKeyIdentity(t, compose, "denied", false)

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

requireStraightThrough(t, compose, sshid, signer)

grant(t, compose, &requests.AccessPolicyCreate{
Name: "denied",
Subject: apiKeySubject(key.ID),
Logins: []string{"*"},
Action: string(models.PolicyActionDeny),
})

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

t.Run("a key's identity is accepted until its expiry and refused after", func(t *testing.T) {
key, signer := enrollAPIKeyIdentity(t, compose, "expiring", new(1), false)

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

live := apiKeyIdentity(t, compose, key.Name)
require.NotNil(t, live.ExpiresAt)
require.True(t, live.Active(time.Now()), //nolint:forbidigo // the expiry the server compares against its own wall clock
"an identity a day from its expiry should be live")

requireStraightThrough(t, compose, sshid, signer)

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

t.Run("deleting a key revokes its identities", func(t *testing.T) {
key, signer := newAPIKeyIdentity(t, compose, "revoked", false)

policy := compose.CreateAccessPolicy(t, &requests.AccessPolicyCreate{
Name: "revoked",
Subject: apiKeySubject(key.ID),
Logins: []string{"*"},
})

requireStraightThrough(t, compose, sshid, signer)

resp, err := compose.R(t.Context()).Delete("/api/namespaces/api-key/" + key.Name)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode(), resp.String())

resp, err = compose.R(t.Context()).Get("/api/access-policies/" + policy.ID)
require.NoError(t, err)
assert.Equal(t, http.StatusNotFound, resp.StatusCode(), "the policy naming the key should go with it: %s", resp.String())

unknown := startLogin(t, compose, sshid, signer)
prompt := unknown.awaitApproval(t)
assert.Equal(t, models.SSHApprovalIdentity, prompt.kind,
"the gateway should no longer know the key, and ask to enroll it as a new one")
})
}
166 changes: 158 additions & 8 deletions tests/api_key_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,15 @@ package main

import (
"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/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"
Expand All @@ -28,14 +32,8 @@ func TestRoutesThatRefuseAPIKeys(t *testing.T) {
OptRole: authorizer.RoleAdministrator,
})

withKey := func(t *testing.T) *resty.Request {
t.Helper()

return compose.Anonymous(t.Context()).SetHeader("X-API-Key", key.Key)
}

t.Run("the key authenticates on a namespace route", func(t *testing.T) {
resp, err := withKey(t).Get("/api/devices")
resp, err := withAPIKey(t, compose, key.Key).Get("/api/devices")
require.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode(), resp.String())
})
Expand Down Expand Up @@ -159,9 +157,161 @@ func TestRoutesThatRefuseAPIKeys(t *testing.T) {

for _, tc := range cases {
t.Run("refuses "+tc.description, func(t *testing.T) {
resp, err := withKey(t).Execute(tc.method, tc.path)
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())
})
}
}

// 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. It also covers a key carrying the instance key prefix, which the server
// honours only on the admin API, being refused on a namespace route. The routes that refuse a key whatever its role are covered by
// [TestRoutesThatRefuseAPIKeys].
func TestNamespaceAPIKeyAuthentication(t *testing.T) {
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)

_, 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, 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.StatusOK, 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.StatusOK, resp.StatusCode(), resp.String())
assert.Equal(t, []string{"staging"}, deviceTags(t))

resp, err = withAPIKey(t, compose, key.Key).Delete("/api/devices/" + device.UID + "/tags/staging")
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode(), resp.String())
assert.Empty(t, deviceTags(t))

resp, err = withAPIKey(t, compose, key.Key).Delete("/api/tags/staging")
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode(), resp.String())

assert.NotContains(t, namespaceTags(t, compose.R(t.Context())), "staging")
})

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.StatusOK, policies: http.StatusForbidden},
{role: authorizer.RoleAdministrator, devices: http.StatusOK, tags: http.StatusOK, 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)

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 == "forever" })
require.GreaterOrEqual(t, index, 0)
assert.Equal(t, int64(-1), keys[index].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 key carrying the instance key prefix is refused on a namespace route", func(t *testing.T) {
resp, err := withAPIKey(t, compose, models.InstanceAPIKeyPrefix+uuid.Generate()).Get("/api/devices")
require.NoError(t, err)
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode(), resp.String())
})

t.Run("a key is refused once its expiry passes", func(t *testing.T) {
const ttl = 10 * 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, time.Second)
})
}

func withAPIKey(t *testing.T, compose *environment.DockerCompose, plaintext string) *resty.Request {
t.Helper()

return compose.Anonymous(t.Context()).SetHeader("X-API-Key", plaintext)
}
2 changes: 1 addition & 1 deletion tests/enrollment_automatic_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ func testAutomaticEnrollment(t *testing.T, compose *environment.DockerCompose) {
device := enroll(t, compose, newKeyedDeviceAuthRequest(t, key, "automatic-tagged", "02:00:00:00:10:06"))

assert.Equal(t, models.DeviceStatusAccepted, device.Status)
assert.ElementsMatch(t, []string{"fleet", "edge"}, tagNames(device))
assert.ElementsMatch(t, []string{"fleet", "edge"}, tagNames(device.Tags))
})

t.Run("the device is ephemeral when the key is", func(t *testing.T) {
Expand Down
2 changes: 1 addition & 1 deletion tests/enrollment_reregistration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ func testReRegistration(t *testing.T, compose *environment.DockerCompose) {
assert.Equal(t, enrolled.UID, reregistered.UID)
assert.Equal(t, models.DeviceStatusAccepted, reregistered.Status)
assert.Equal(t, key.ID, reregistered.ProvisioningKeyID)
assert.ElementsMatch(t, []string{"enrolled", "reenrolled"}, tagNames(reregistered),
assert.ElementsMatch(t, []string{"enrolled", "reenrolled"}, tagNames(reregistered.Tags),
"re-registration adds the key's current tags to those the removed device kept")
assert.True(t, reregistered.Ephemeral)
assert.Equal(t, 4, reregistered.EphemeralTimeout)
Expand Down
6 changes: 3 additions & 3 deletions tests/enrollment_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,9 +92,9 @@ func awaitStatusOnReauth(t *testing.T, compose *environment.DockerCompose, req r
}, deviceAuthCacheTTL+30*time.Second, 2*time.Second)
}

func tagNames(device models.Device) []string {
names := make([]string, 0, len(device.Tags))
for _, tag := range device.Tags {
func tagNames(tags []models.Tag) []string {
names := make([]string, 0, len(tags))
for _, tag := range tags {
names = append(names, tag.Name)
}

Expand Down
18 changes: 18 additions & 0 deletions tests/environment/api_key.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package environment

import (
"testing"
"time"
)

// ExpireAPIKeyIn moves the expiry of the API key named name to ttl from now, failing t unless
// exactly that key changed. The API sets an expiry only in whole days ahead, so it writes the row
// directly, standing in for the days a real key waits to expire. A key the server has already
// cached keeps the expiry it was cached with, so call it before the key's first use.
func (dc *DockerCompose) ExpireAPIKeyIn(t *testing.T, name string, ttl time.Duration) {
t.Helper()

dc.updateOne(t,
"UPDATE api_keys SET expires_in = extract(epoch FROM now() + :'ttl'::interval)::bigint WHERE name = :'name'",
map[string]string{"name": name, "ttl": interval(ttl)})
}
38 changes: 38 additions & 0 deletions tests/environment/user.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package environment

import (
"regexp"
"testing"

"github.com/stretchr/testify/require"
)

// SetUserPasswordDigest replaces the stored password digest of the user username with digest,
// failing t unless exactly that user changed. Every path that sets a password hashes it with
// bcrypt, so it writes the row directly, standing in for an account created before that.
func (dc *DockerCompose) SetUserPasswordDigest(t *testing.T, username, digest string) {
t.Helper()

dc.updateOne(t,
"UPDATE users SET password_digest = :'digest' WHERE username = :'username'",
map[string]string{"username": username, "digest": digest})
}

var passwordDigestPattern = regexp.MustCompile(`digest=(\S+)`)

// UserPasswordDigest returns the stored password digest of the user username, reading the row
// directly because no route returns it. It fails t unless psql runs the query and prints a
// non-empty digest, so a missing user and a user with no digest both fail it.
func (dc *DockerCompose) UserPasswordDigest(t *testing.T, username string) string {
t.Helper()

output, err := dc.stack.SQL(t.Context(),
"SELECT 'digest=' || password_digest FROM users WHERE username = :'username'",
map[string]string{"username": username})
require.NoError(t, err)

match := passwordDigestPattern.FindStringSubmatch(output)
require.NotNil(t, match, "psql printed no digest for %s: %s", username, output)

return match[1]
}
Loading
Loading