Files
RenewCMS/test/roles_permissions_enforcement_http_test.go
T

506 lines
21 KiB
Go

package test
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/domain/user"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
const (
enforcementOwnerID uint32 = 3
enforcementAdminID uint32 = 5
enforcementWriterID uint32 = 9
enforcementUnverified uint32 = 12
enforcementOrphanID uint32 = 13
enforcementCustomID uint32 = 20
)
type gateOutcome struct {
rec *httptest.ResponseRecorder
reached bool
}
func (g gateOutcome) blocked() bool {
return !g.reached && g.rec.Code == http.StatusForbidden && g.rec.Body.String() == apperr.MissingPermissionErrorMessage+"\n"
}
func newEnforcementEnv(t *testing.T) *apiKeyEnv {
t.Helper()
e := newAPIKeyEnv(t)
e.addUser(enforcementOwnerID, user.RoleOwner, ownerRoleID, true)
e.addUser(enforcementAdminID, user.RoleAdmin, adminRoleID, true)
e.addUser(enforcementWriterID, "", writerRoleID, true)
e.addUser(enforcementUnverified, user.RoleAdmin, adminRoleID, false)
e.addUser(enforcementOrphanID, "", 99, true)
return e
}
func (e *apiKeyEnv) customRoleUser(id uint32, grants ...role.Grant) role.Role {
custom, _ := e.roles.Create(role.Role{Name: "Custom " + string(rune('a'+id)), Version: 1, Grants: grants})
e.addUser(id, "", custom.ID, true)
return custom
}
func (e *apiKeyEnv) gateRequest(t *testing.T, req *http.Request) gateOutcome {
t.Helper()
outcome := gateOutcome{rec: httptest.NewRecorder()}
func() {
defer func() {
if recover() != nil {
outcome.reached = true
}
}()
e.router.ServeHTTP(outcome.rec, req)
}()
return outcome
}
func (e *apiKeyEnv) gateAs(t *testing.T, userID uint32, method, path, body string) gateOutcome {
t.Helper()
req := httptest.NewRequest(method, path, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.AddCookie(e.cookie(t, userID, 0))
return e.gateRequest(t, req)
}
func (e *apiKeyEnv) gateWithKey(t *testing.T, raw, method, path, body string) gateOutcome {
t.Helper()
req := httptest.NewRequest(method, path, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+raw)
return e.gateRequest(t, req)
}
func assertGate(t *testing.T, got gateOutcome, wantKey string) {
t.Helper()
if wantKey == "" {
if got.blocked() {
t.Fatalf("expected the gate to pass, got %d %q (key %q)", got.rec.Code, got.rec.Body.String(), got.rec.Header().Get(apperr.MissingPermissionHeader))
}
return
}
if !got.blocked() {
t.Fatalf("expected missing_permission for %s, got reached=%v %d %q", wantKey, got.reached, got.rec.Code, got.rec.Body.String())
}
if header := got.rec.Header().Get(apperr.MissingPermissionHeader); header != wantKey {
t.Fatalf("expected %s header %q, got %q", apperr.MissingPermissionHeader, wantKey, header)
}
}
type gateRow struct {
caller uint32
method string
path string
body string
key string
}
func runGateRows(t *testing.T, rows []gateRow) {
t.Helper()
for _, row := range rows {
name := roleNameOf(row.caller) + " " + row.method + " " + row.path + " -> " + resultOf(row.key)
t.Run(name, func(t *testing.T) {
e := newEnforcementEnv(t)
assertGate(t, e.gateAs(t, row.caller, row.method, row.path, row.body), row.key)
})
}
}
func roleNameOf(id uint32) string {
switch id {
case enforcementOwnerID:
return "Owner"
case enforcementAdminID:
return "Admin"
case enforcementWriterID:
return "Writer"
}
return "user"
}
func resultOf(key string) string {
if key == "" {
return "ok"
}
return "403 " + key
}
func TestRolesPermissionsEnforcementHTTP(t *testing.T) {
const (
owner = enforcementOwnerID
admin = enforcementAdminID
writer = enforcementWriterID
)
t.Run("A refused request carries the missing_permission contract", func(t *testing.T) {
e := newEnforcementEnv(t)
got := e.gateAs(t, writer, http.MethodPost, "/v1/user/invite", `{"email": "new@example.com"}`)
assertGate(t, got, "users.invite")
if got.rec.Header().Get("Content-Type") == "" {
t.Error("expected a content type")
}
if len(e.invites.invites) != 0 || len(e.mail.SentEmails) != 0 {
t.Errorf("a refused invite must have no effect: %d invites, %d mails", len(e.invites.invites), len(e.mail.SentEmails))
}
})
t.Run("Verification and token checks still come first", func(t *testing.T) {
e := newEnforcementEnv(t)
unverified := e.send(t, apiKeyRequest{method: http.MethodPost, path: "/v1/user/invite", cookieUser: enforcementUnverified})
assertAPIKeyMessage(t, unverified, http.StatusForbidden, apperr.VerificationRequiredErrorMessage)
stored := e.users.users[writer]
stored.TokenVersion = 4
e.users.users[writer] = stored
stale := e.send(t, apiKeyRequest{method: http.MethodPost, path: "/v1/user/invite", cookieUser: writer, cookieVersion: 0})
assertAPIKeyMessage(t, stale, http.StatusUnauthorized, apperr.UnauthorizedErrorMessage)
anonymous := e.anonymous(t, http.MethodPost, "/v1/user/invite", "")
assertAPIKeyMessage(t, anonymous, http.StatusUnauthorized, apperr.UnauthorizedErrorMessage)
})
t.Run("Users and invites", func(t *testing.T) {
runGateRows(t, []gateRow{
{owner, http.MethodGet, "/v1/user", "", ""},
{admin, http.MethodGet, "/v1/user", "", ""},
{writer, http.MethodGet, "/v1/user", "", "users.view"},
{owner, http.MethodPost, "/v1/user/invite", "", ""},
{admin, http.MethodPost, "/v1/user/invite", "", ""},
{writer, http.MethodPost, "/v1/user/invite", "", "users.invite"},
{admin, http.MethodDelete, "/v1/user/invite/1", "", ""},
{writer, http.MethodDelete, "/v1/user/invite/1", "", "users.invite"},
{admin, http.MethodPost, "/v1/user/invite/1/renew", "", ""},
{writer, http.MethodPost, "/v1/user/invite/1/renew", "", "users.invite"},
})
})
t.Run("Settings, sites and system", func(t *testing.T) {
runGateRows(t, []gateRow{
{owner, http.MethodGet, "/v1/settings", "", ""},
{admin, http.MethodGet, "/v1/settings", "", ""},
{writer, http.MethodGet, "/v1/settings", "", "settings.view"},
{owner, http.MethodPatch, "/v1/settings", "", ""},
{admin, http.MethodPatch, "/v1/settings", "", "system.edit_retention"},
{writer, http.MethodPatch, "/v1/settings", "", "system.edit_retention"},
{owner, http.MethodPost, "/v1/site", "", ""},
{admin, http.MethodPost, "/v1/site", "", ""},
{writer, http.MethodPost, "/v1/site", "", "settings.manage_sites"},
{admin, http.MethodPatch, "/v1/site/2", "", ""},
{admin, http.MethodPatch, "/v1/site/2/default", "", ""},
{writer, http.MethodPatch, "/v1/site/2", "", "settings.manage_sites"},
{owner, http.MethodDelete, "/v1/site/2", "", ""},
{admin, http.MethodDelete, "/v1/site/2", "", "settings.delete_site"},
{writer, http.MethodDelete, "/v1/site/2", "", "settings.delete_site"},
{writer, http.MethodGet, "/v1/site", "", ""},
{owner, http.MethodGet, "/v1/system/update", "", ""},
{admin, http.MethodGet, "/v1/system/update", "", ""},
{writer, http.MethodGet, "/v1/system/update", "", "settings.view"},
{owner, http.MethodPost, "/v1/system/update/check", "", ""},
{admin, http.MethodPost, "/v1/system/update/check", "", "system.apply_updates"},
{owner, http.MethodPost, "/v1/system/update/download", "", ""},
{admin, http.MethodPost, "/v1/system/update/download", "", "system.apply_updates"},
{owner, http.MethodPost, "/v1/system/update/restart", "", ""},
{admin, http.MethodPost, "/v1/system/update/restart", "", "system.apply_updates"},
{writer, http.MethodPost, "/v1/system/update/restart", "", "system.apply_updates"},
})
})
t.Run("The permission gate runs before the update rate limiters", func(t *testing.T) {
e := newEnforcementEnv(t)
for attempt := 1; attempt <= 6; attempt++ {
assertGate(t, e.gateAs(t, admin, http.MethodPost, "/v1/system/update/check", ""), "system.apply_updates")
}
})
t.Run("The permission gate runs before the invite rate limiter", func(t *testing.T) {
e := newEnforcementEnv(t)
e.settings.AuthRateLimit.MaxAttempts = 1
e.rebuildRouter()
first := e.gateAs(t, owner, http.MethodPost, "/v1/user/invite", `{"email": "first@example.com"}`)
if first.blocked() {
t.Fatalf("owner invite refused: %q", first.rec.Body.String())
}
if again := e.gateAs(t, owner, http.MethodPost, "/v1/user/invite", `{"email": "second@example.com"}`); again.rec.Code != http.StatusTooManyRequests {
t.Fatalf("limiter is not exhausted, status %d", again.rec.Code)
}
got := e.gateAs(t, writer, http.MethodPost, "/v1/user/invite", `{"email": "third@example.com"}`)
assertGate(t, got, "users.invite")
})
t.Run("An admin reads the update status but cannot apply it", func(t *testing.T) {
e := newEnforcementEnv(t)
e.container.GetUpdateStatusUseCase = newUpdateStatusUseCase(e.updates, &fakeJobStore{}, &fakeHelperDetector{installed: true}, apiKeyRunningVersion)
e.rebuildRouter()
canApply := func(id uint32) bool {
rec := e.withCookie(t, http.MethodGet, "/v1/system/update", "", id)
assertAPIKeyStatus(t, rec, http.StatusOK)
var body struct {
CanApply bool `json:"can_apply"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
return body.CanApply
}
if canApply(admin) {
t.Error("admin must not be able to apply")
}
if !canApply(owner) {
t.Error("owner must be able to apply with the helper installed")
}
})
t.Run("An admin reads the activity retention but cannot change it", func(t *testing.T) {
e := newEnforcementEnv(t)
rec := e.withCookie(t, http.MethodGet, "/v1/settings", "", admin)
assertAPIKeyStatus(t, rec, http.StatusOK)
for _, field := range []string{"activity_retention_days", "activity_retention_max_gb"} {
if !strings.Contains(rec.Body.String(), field) {
t.Errorf("settings body lacks %s: %s", field, rec.Body.String())
}
}
assertGate(t, e.gateAs(t, admin, http.MethodPatch, "/v1/settings", `{"activity_retention_days": 30}`), "system.edit_retention")
})
t.Run("Authors, schema, media and API key creation", func(t *testing.T) {
runGateRows(t, []gateRow{
{owner, http.MethodGet, "/v1/author", "", ""},
{writer, http.MethodGet, "/v1/author", "", ""},
{admin, http.MethodPost, "/v1/author", "", ""},
{writer, http.MethodPost, "/v1/author", "", "authors.create"},
{admin, http.MethodDelete, "/v1/author/4", "", ""},
{writer, http.MethodDelete, "/v1/author/4", "", "authors.delete"},
{writer, http.MethodPost, "/v1/author/me", "", ""},
{admin, http.MethodGet, "/v1/content-type", "", ""},
{writer, http.MethodGet, "/v1/content-type", "", ""},
{writer, http.MethodGet, "/v1/content-type/events", "", ""},
{admin, http.MethodPost, "/v1/content-type", "", ""},
{writer, http.MethodPost, "/v1/content-type", "", "schema.edit"},
{writer, http.MethodPut, "/v1/content-type/events", "", "schema.edit"},
{admin, http.MethodDelete, "/v1/content-type/events", "", ""},
{writer, http.MethodDelete, "/v1/content-type/events", "", "schema.delete"},
{writer, http.MethodGet, "/v1/builtin-type", "", ""},
{writer, http.MethodGet, "/v1/media", "", ""},
{writer, http.MethodGet, "/v1/media/1", "", ""},
{writer, http.MethodGet, "/v1/media/1/usage", "", ""},
{writer, http.MethodPost, "/v1/media", "", ""},
{writer, http.MethodPatch, "/v1/media/1", "", ""},
{writer, http.MethodDelete, "/v1/media/1", "", "media.delete"},
{writer, http.MethodPost, "/v1/media/merge", "", "media.delete"},
{admin, http.MethodDelete, "/v1/media/1", "", ""},
{writer, http.MethodGet, "/v1/media/purge-settings", "", "settings.view"},
{writer, http.MethodPatch, "/v1/media/purge-settings", "", "settings.edit_general"},
{writer, http.MethodGet, "/v1/media/purge-preview", "", "media.delete"},
{writer, http.MethodPost, "/v1/media/purge", "", "media.delete"},
{admin, http.MethodPost, "/v1/media/purge", "", ""},
{writer, http.MethodGet, "/v1/media/1/variants", "", ""},
{writer, http.MethodPost, "/v1/media/1/variants", "", "settings.edit_general"},
{writer, http.MethodDelete, "/v1/media/1/variants/hero", "", "settings.edit_general"},
{writer, http.MethodGet, "/v1/image-variants", "", ""},
{writer, http.MethodGet, "/v1/image-variants/cache", "", "settings.view"},
{writer, http.MethodPost, "/v1/image-variants", "", "settings.edit_general"},
{writer, http.MethodPost, "/v1/image-variants/cache/purge", "", "settings.edit_general"},
{admin, http.MethodPut, "/v1/image-variants/hero", "", ""},
{admin, http.MethodPost, "/v1/api-key", `{"name": "ci"}`, ""},
{writer, http.MethodPost, "/v1/api-key", `{"name": "ci"}`, "apikeys.create"},
})
})
t.Run("Routes that keep their old behaviour for API keys", func(t *testing.T) {
e := newEnforcementEnv(t)
raw, _ := e.issueKey(admin, "ci")
for _, route := range [][2]string{{http.MethodPost, "/v1/api-key"}, {http.MethodGet, "/v1/activity"}, {http.MethodPost, "/v1/system/update/check"}} {
rec := e.withKey(t, route[0], route[1], "", raw)
assertAPIKeyMessage(t, rec, http.StatusForbidden, apperr.SessionRequiredErrorMessage)
}
rec := e.withKey(t, http.MethodGet, "/v1/media/1/variants", "", raw)
assertAPIKeyMessage(t, rec, http.StatusUnauthorized, apperr.UnauthorizedErrorMessage)
})
t.Run("Roles are judged by the keys they hold, with no implied keys", func(t *testing.T) {
rows := []struct {
grants []role.Grant
method string
path string
key string
}{
{grantsOf("schema.edit"), http.MethodPost, "/v1/content-type", ""},
{grantsOf("schema.edit"), http.MethodGet, "/v1/content-type", "schema.view"},
{grantsOf("schema.view"), http.MethodPut, "/v1/content-type/events", "schema.edit"},
{grantsOf("users.invite"), http.MethodGet, "/v1/user", "users.view"},
{grantsOf("users.view"), http.MethodPost, "/v1/user/invite", "users.invite"},
{grantsOf("settings.manage_sites"), http.MethodDelete, "/v1/site/2", "settings.delete_site"},
{grantsOf("settings.delete_site"), http.MethodPatch, "/v1/site/2", "settings.manage_sites"},
{grantsOf("media.upload"), http.MethodGet, "/v1/media", "media.view"},
{grantsOf("media.view"), http.MethodPost, "/v1/media", "media.upload"},
{grantsOf("apikeys.create"), http.MethodGet, "/v1/api-key", "apikeys.view"},
{grantsOf("settings.edit_general"), http.MethodPatch, "/v1/media/purge-settings", ""},
{grantsOf("system.apply_updates"), http.MethodPost, "/v1/system/update/check", "system.apply_updates"},
{nil, http.MethodGet, "/v1/author", "authors.view"},
}
for _, row := range rows {
names := []string{}
for _, grant := range row.grants {
names = append(names, grant.Key)
}
t.Run(strings.Join(names, ",")+" "+row.method+" "+row.path+" -> "+resultOf(row.key), func(t *testing.T) {
e := newEnforcementEnv(t)
e.customRoleUser(enforcementCustomID, row.grants...)
assertGate(t, e.gateAs(t, enforcementCustomID, row.method, row.path, ""), row.key)
})
}
})
t.Run("A user whose role cannot be resolved is refused everywhere that is gated", func(t *testing.T) {
e := newEnforcementEnv(t)
assertGate(t, e.gateAs(t, enforcementOrphanID, http.MethodGet, "/v1/user", ""), "users.view")
assertGate(t, e.gateAs(t, enforcementOrphanID, http.MethodGet, "/v1/content-type", ""), "schema.view")
assertGate(t, e.gateAs(t, enforcementOrphanID, http.MethodPost, "/v1/media", ""), "media.upload")
})
t.Run("A repository failure while resolving the role is a server error", func(t *testing.T) {
e := newEnforcementEnv(t)
e.roles.fail.failNext("Get")
rec := e.withCookie(t, http.MethodGet, "/v1/user", "", owner)
assertAPIKeyMessage(t, rec, http.StatusInternalServerError, "Internal server error")
})
t.Run("The legacy role string is no longer read for authorization", func(t *testing.T) {
rows := []struct {
id uint32
legacy user.Role
roleID uint32
method string
path string
key string
}{
{14, user.RoleOwner, writerRoleID, http.MethodPost, "/v1/user/invite", "users.invite"},
{15, user.RoleAdmin, writerRoleID, http.MethodPost, "/v1/content-type", "schema.edit"},
{16, "", adminRoleID, http.MethodPost, "/v1/content-type", ""},
{17, user.RoleOwner, 0, http.MethodPost, "/v1/user/invite", ""},
{18, user.RoleAdmin, 0, http.MethodPost, "/v1/content-type", ""},
{19, user.RoleAdmin, 0, http.MethodPatch, "/v1/settings", "system.edit_retention"},
}
for _, row := range rows {
t.Run(string(row.legacy)+" "+row.method+" "+row.path+" -> "+resultOf(row.key), func(t *testing.T) {
e := newEnforcementEnv(t)
e.addUser(row.id, row.legacy, row.roleID, true)
assertGate(t, e.gateAs(t, row.id, row.method, row.path, ""), row.key)
})
}
})
t.Run("A session follows its role live", func(t *testing.T) {
e := newEnforcementEnv(t)
assertGate(t, e.gateAs(t, writer, http.MethodGet, "/v1/media/purge-settings", ""), "settings.view")
stored := e.roles.roles[writerRoleID]
stored.Grants = append(stored.Grants, role.Grant{Key: "settings.view", Scope: role.ScopeAll})
e.roles.roles[writerRoleID] = stored
rec := e.withCookie(t, http.MethodGet, "/v1/settings", "", writer)
assertAPIKeyStatus(t, rec, http.StatusOK)
})
t.Run("Owner-only items stay owner-only", func(t *testing.T) {
e := newEnforcementEnv(t)
for _, route := range [][2]string{{http.MethodDelete, "/v1/site/2"}, {http.MethodPatch, "/v1/settings"}, {http.MethodPost, "/v1/system/update/check"}, {http.MethodPost, "/v1/system/update/download"}, {http.MethodPost, "/v1/system/update/restart"}} {
if !e.gateAs(t, admin, route[0], route[1], "").blocked() {
t.Errorf("admin passed %s %s", route[0], route[1])
}
if e.gateAs(t, owner, route[0], route[1], "").blocked() {
t.Errorf("owner blocked on %s %s", route[0], route[1])
}
}
})
t.Run("An API key resolves its creator's current role on every request", func(t *testing.T) {
e := newEnforcementEnv(t)
raw, _ := e.issueKey(admin, "ci")
assertGate(t, e.gateWithKey(t, raw, http.MethodGet, "/v1/content-type", ""), "")
assertGate(t, e.gateWithKey(t, raw, http.MethodPost, "/v1/user/invite", `{"email": "k@example.com"}`), "")
changed := e.users.users[admin]
changed.RoleID = writerRoleID
e.users.users[admin] = changed
assertGate(t, e.gateWithKey(t, raw, http.MethodPost, "/v1/user/invite", `{"email": "k2@example.com"}`), "users.invite")
assertGate(t, e.gateWithKey(t, raw, http.MethodGet, "/v1/content-type", ""), "")
assertGate(t, e.gateWithKey(t, raw, http.MethodPost, "/v1/content-type", ""), "schema.edit")
})
t.Run("A key grows when its creator's role grows", func(t *testing.T) {
e := newEnforcementEnv(t)
raw, _ := e.issueKey(writer, "ci")
assertGate(t, e.gateWithKey(t, raw, http.MethodPost, "/v1/content-type", ""), "schema.edit")
changed := e.users.users[writer]
changed.RoleID = adminRoleID
e.users.users[writer] = changed
assertGate(t, e.gateWithKey(t, raw, http.MethodPost, "/v1/content-type", ""), "")
})
t.Run("Editing the creator's role grants shrinks the key as well", func(t *testing.T) {
e := newEnforcementEnv(t)
custom := e.customRoleUser(enforcementCustomID, grantsOf("media.view", "media.upload")...)
raw, _ := e.issueKey(enforcementCustomID, "ci")
assertGate(t, e.gateWithKey(t, raw, http.MethodPost, "/v1/media", ""), "")
stored := e.roles.roles[custom.ID]
stored.Grants = grantsOf("media.view")
e.roles.roles[custom.ID] = stored
assertGate(t, e.gateWithKey(t, raw, http.MethodPost, "/v1/media", ""), "media.upload")
assertGate(t, e.gateWithKey(t, raw, http.MethodGet, "/v1/media", ""), "")
})
t.Run("A key whose creator's role cannot be resolved is refused by the gate", func(t *testing.T) {
e := newEnforcementEnv(t)
raw, _ := e.issueKey(enforcementOrphanID, "ci")
assertGate(t, e.gateWithKey(t, raw, http.MethodGet, "/v1/media", ""), "media.view")
})
t.Run("Key authentication failures are unchanged", func(t *testing.T) {
e := newEnforcementEnv(t)
rec := e.withKey(t, http.MethodGet, "/v1/media", "", "rcms_unknownunknownunknownunknownunknownunknown1")
assertAPIKeyMessage(t, rec, http.StatusUnauthorized, apperr.UnauthorizedErrorMessage)
})
t.Run("Public and self-service routes keep no permission gate", func(t *testing.T) {
e := newEnforcementEnv(t)
e.customRoleUser(enforcementCustomID)
for _, route := range [][2]string{{http.MethodGet, "/v1/auth/me"}, {http.MethodGet, "/v1/permission"}, {http.MethodGet, "/v1/site"}, {http.MethodGet, "/v1/user/lookup?ids=1"}, {http.MethodPatch, "/v1/user/me"}, {http.MethodPost, "/v1/author/me"}} {
if e.gateAs(t, enforcementCustomID, route[0], route[1], `{}`).blocked() {
t.Errorf("%s %s is gated", route[0], route[1])
}
}
if e.gateAs(t, enforcementCustomID, http.MethodGet, "/v1/article", "").blocked() {
t.Error("article list is gated")
}
})
t.Run("Articles and entries are gated by the content keys since the content scope slice", func(t *testing.T) {
e := newEnforcementEnv(t)
e.customRoleUser(enforcementCustomID)
rows := []struct {
method, path, key string
}{
{http.MethodPost, "/v1/article", "content.create"},
{http.MethodDelete, "/v1/article/1", "content.delete"},
{http.MethodGet, "/v1/content-type/events/entry", "content.read"},
{http.MethodPost, "/v1/content-type/events/entry", "content.create"},
}
for _, row := range rows {
assertGate(t, e.gateAs(t, enforcementCustomID, row.method, row.path, `{}`), row.key)
}
})
}
func grantsOf(keys ...string) []role.Grant {
grants := make([]role.Grant, 0, len(keys))
for _, key := range keys {
grants = append(grants, role.Grant{Key: key, Scope: role.ScopeAll})
}
return grants
}