package test import ( "RenewCMS/api/pkg/apperr" "RenewCMS/internal/domain/role" "encoding/json" "net/http" "net/http/httptest" "strconv" "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, ownerRoleID, true) e.addUser(enforcementAdminID, adminRoleID, true) e.addUser(enforcementWriterID, writerRoleID, true) e.addUser(enforcementUnverified, 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("Only role_id decides authorization, and role_id 0 is denied", func(t *testing.T) { rows := []struct { id uint32 roleID uint32 method string path string key string }{ {14, writerRoleID, http.MethodPost, "/v1/user/invite", "users.invite"}, {15, writerRoleID, http.MethodPost, "/v1/content-type", "schema.edit"}, {16, adminRoleID, http.MethodPost, "/v1/content-type", ""}, {17, 0, http.MethodPost, "/v1/user/invite", "users.invite"}, {18, 0, http.MethodPost, "/v1/content-type", "schema.edit"}, {19, 0, http.MethodPatch, "/v1/settings", "system.edit_retention"}, } for _, row := range rows { t.Run(strconv.Itoa(int(row.roleID))+" "+row.method+" "+row.path+" -> "+resultOf(row.key), func(t *testing.T) { e := newEnforcementEnv(t) e.addUser(row.id, 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 }