From 75b71da7b73ad1150f050a101504da985c00972e Mon Sep 17 00:00:00 2001 From: grendervill Date: Mon, 21 Sep 2026 01:40:17 +0300 Subject: [PATCH] =?UTF-8?q?fix(roles):=20=D1=80=D0=BE=D0=BB=D0=B8=20=D0=BD?= =?UTF-8?q?=D0=B0=20=D1=81=D0=B5=D0=B1=D1=8F=20=D0=B2=D1=8B=D0=B4=D0=B0?= =?UTF-8?q?=D0=B2=D0=B0=D1=82=D1=8C=20=D0=BC=D0=BE=D0=B6=D0=BD=D0=BE,=20?= =?UTF-8?q?=D1=81=D0=B0=D0=BC=D0=BE=D0=BC=D0=BE=D0=B4=D0=B5=D1=80=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D1=8F=20=D0=B7=D0=B0=D0=BF=D1=80=D0=B5=D1=89=D0=B5?= =?UTF-8?q?=D0=BD=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Владелец сервера или участник с MANAGE_ROLES не мог выдать роль себе: общий запрет самомодерации (кик, бан, тайм-аут) распространялся и на оформление. Проверка иерархии для ролей вынесена отдельно: себя менять можно, владельца и администратора инстанса — по-прежнему нет. --- internal/server/api_guilds.go | 38 ++++++++++++++++++++++++- internal/server/cosmetics_test.go | 46 +++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 1 deletion(-) diff --git a/internal/server/api_guilds.go b/internal/server/api_guilds.go index 06acc74..32f340a 100644 --- a/internal/server/api_guilds.go +++ b/internal/server/api_guilds.go @@ -957,7 +957,9 @@ func (s *Server) changeMemberRole(ctx context.Context, guildIDRaw, userIDRaw, ro if err != nil { return nil, err } - if err := s.checkMemberHierarchy(ctx, guildID, user, resolved, targetID); err != nil { + // Роли на себя выдавать можно (MANAGE_ROLES уже проверено): запрет + // самомодерации касается кика, бана и тайм-аута, а не оформления. + if err := s.checkRoleHierarchy(ctx, guildID, user, resolved, targetID); err != nil { return nil, err } role, err := s.store.GetRole(ctx, roleID) @@ -1006,6 +1008,40 @@ func (s *Server) changeMemberRole(ctx context.Context, guildIDRaw, userIDRaw, ro return newOKOutput(), nil } +// checkRoleHierarchy — проверка иерархии для выдачи и снятия ролей: в отличие +// от модерации, свои роли менять можно (владелец сервера не теряет владение), +// но владельца и администратора инстанса по-прежнему не трогаем. +func (s *Server) checkRoleHierarchy(ctx context.Context, guildID uint64, actor *store.User, resolved permissions.Resolved, targetID uint64) error { + if actor.IsInstanceAdmin { + return nil + } + guild, err := s.store.GetGuild(ctx, guildID) + if err != nil { + return humaError(err) + } + if targetID == guild.OwnerID && actor.ID != guild.OwnerID { + return humaErrorStatus(http.StatusForbidden, "perm.denied", "guild owner cannot be moderated") + } + if target, err := s.store.GetUser(ctx, targetID); err == nil && target.IsInstanceAdmin && actor.ID != targetID { + return humaErrorStatus(http.StatusForbidden, "instance.admin_protected", "instance administrator cannot be moderated") + } + if targetID == actor.ID { + return nil + } + actorRoles, err := s.roleData(ctx, guildID, actor.ID) + if err != nil { + return err + } + targetRoles, err := s.roleData(ctx, guildID, targetID) + if err != nil { + return err + } + if !permissions.OutranksMember(actorRoles, targetRoles, resolved.IsOwner, targetID == guild.OwnerID) { + return humaErrorStatus(http.StatusForbidden, "perm.denied", "member has a higher role") + } + return nil +} + // checkMemberHierarchy запрещает действовать против владельца и участников с // более высокими ролями (AGENT.md 6.3, 7.17). func (s *Server) checkMemberHierarchy(ctx context.Context, guildID uint64, actor *store.User, resolved permissions.Resolved, targetID uint64) error { diff --git a/internal/server/cosmetics_test.go b/internal/server/cosmetics_test.go index a25795a..3d25208 100644 --- a/internal/server/cosmetics_test.go +++ b/internal/server/cosmetics_test.go @@ -302,3 +302,49 @@ func TestPersonalStyleAvailability(t *testing.T) { t.Fatalf("сброс стиля = %d", rec.Code) } } + +func TestSelfRoleAssignmentForCosmetics(t *testing.T) { + srv, owner, _, guildID, _ := cosmeticsFixture(t) + frameID, status := uploadCosmetic(t, srv, guildID, "frame", "Своя", owner) + if status != http.StatusOK { + t.Fatalf("загрузка рамки: статус %d", status) + } + // Свою роль выдать можно: иначе владелец не примерит оформление. + created := doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guildID+"/roles", + `{"name":"Своя роль","cosmetic_frame_id":"`+frameID+`"}`, owner) + roleID := decodeResponse[struct { + Role struct { + ID string `json:"id"` + } `json:"role"` + }](t, created).Role.ID + + me := doJSON(t, srv, http.MethodGet, "/api/v1/users/@me", "", owner) + ownerID := decodeResponse[struct { + User struct { + ID string `json:"id"` + } `json:"user"` + }](t, me).User.ID + + granted := doJSON(t, srv, http.MethodPut, + "/api/v1/guilds/"+guildID+"/members/"+ownerID+"/roles/"+roleID, "", owner) + if granted.Code != http.StatusOK { + t.Fatalf("выдача роли себе = %d (%s)", granted.Code, granted.Body.String()) + } + effective := memberCosmetics(t, srv, guildID, ownerID, owner) + if effective.FrameID != frameID { + t.Fatalf("рамка не применилась: %+v", effective) + } + + // Свою роль можно и снять (владелец остаётся владельцем). + revoked := doJSON(t, srv, http.MethodDelete, + "/api/v1/guilds/"+guildID+"/members/"+ownerID+"/roles/"+roleID, "", owner) + if revoked.Code != http.StatusOK { + t.Fatalf("снятие своей роли = %d (%s)", revoked.Code, revoked.Body.String()) + } + + // Самомодерация по-прежнему запрещена: кик себя отклоняется. + kick := doJSON(t, srv, http.MethodDelete, "/api/v1/guilds/"+guildID+"/members/"+ownerID, "", owner) + if kick.Code != http.StatusForbidden { + t.Fatalf("кик себя = %d, ожидался 403", kick.Code) + } +}