feat: more role backend and admin/writer roles additions and adjustments
This commit is contained in:
1 parent
8c6c6456da
commit
de142f6c5f
131 files changed
+2663
-435
No files matched your search
@@ -0,0 +1,499 @@
|
||||
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", ""},
|
||||
{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 not gated in this slice", func(t *testing.T) {
|
||||
e := newEnforcementEnv(t)
|
||||
e.customRoleUser(enforcementCustomID)
|
||||
for _, route := range [][2]string{{http.MethodPost, "/v1/article"}, {http.MethodDelete, "/v1/article/1"}, {http.MethodGet, "/v1/content-type/events/entry"}, {http.MethodPost, "/v1/content-type/events/entry"}} {
|
||||
if e.gateAs(t, enforcementCustomID, route[0], route[1], `{}`).blocked() {
|
||||
t.Errorf("%s %s is gated", route[0], route[1])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user