feat(api): ручки Фаз(ы) 1 — профиль, серверы, роли, админ инстанса
REST-слой Фазы 1 на huma (OpenAPI 3.1 генерируется из кода):
- профиль: GET/PATCH /users/@me, смена пароля со step-up, публичный профиль,
завершение онбординга (новая миграция 00003 с onboarding_completed_at);
- серверы: создание/изменение/удаление, join/leave, список серверов
пользователя, журнал действий;
- комнаты: список с учётом прав, создание/изменение/удаление;
- участники: список с профилями и ролями, никнейм, тайм-аут, исключение;
- роли: CRUD, выдача/снятие с проверкой иерархии и запретом выдачи прав выше
собственных;
- админ инстанса: публичная информация, настройки, серверы, пользователи,
аудит, выдача прав администратора со step-up; обход лимитов фиксируется в
аудите отдельной записью limits.bypass;
- движок прав: участие в сервере стало обязательным условием (IsMember),
не участник не получает прав роли @user; калькулятор прав общий для API и
Gateway, инвалидация кэша после изменений;
- Gateway: браузерный клиент аутентифицируется cookie на рукопожатии, IDENTIFY
без токена использует её; события GUILD/CHANNEL/MEMBER/ROLE рассылаются из
ручек, USER_UPDATE — адресно;
- ошибки huma отдаются в едином конверте {"error":{"code","message"}}.
Тесты: 8 сценариев API (профиль, жизненный цикл сервера и права, лимиты и
обход админом, иерархия ролей, тайм-аут, скрытие комнаты оверрайдом,
членство в движке прав, cookie-идентификация Gateway).
This commit is contained in:
@@ -18,6 +18,11 @@ func (c *Calculator) Channel(ctx context.Context, guildID, channelID, userID uin
|
||||
return Resolved{}, err
|
||||
}
|
||||
resolved.Channel = resolved.Guild
|
||||
if !resolved.IsMember && !resolved.IsInstanceAdmin {
|
||||
// Не участник не видит ничего, даже если есть персональный оверрайд.
|
||||
c.cache.PutChannel(channelID, userID, resolved)
|
||||
return resolved, nil
|
||||
}
|
||||
if resolved.IsInstanceAdmin || resolved.Guild.Has(Administrator) {
|
||||
c.cache.PutChannel(channelID, userID, resolved)
|
||||
return resolved, nil
|
||||
|
||||
@@ -158,6 +158,9 @@ type Resolved struct {
|
||||
IsOwner bool
|
||||
// IsInstanceAdmin — глобальная роль «Администратор сервера» (AGENT.md 7.19).
|
||||
IsInstanceAdmin bool
|
||||
// IsMember — состоит ли пользователь в сервере. Не участник не получает
|
||||
// прав роли @user: сервер для него невидим (AGENT.md 6.2).
|
||||
IsMember bool
|
||||
// TimedOut — участник в тайм-ауте: отправка сообщений запрещена (AGENT.md 7.10).
|
||||
TimedOut bool
|
||||
}
|
||||
@@ -167,6 +170,9 @@ func (r Resolved) Has(permission Permission) bool {
|
||||
if r.IsInstanceAdmin {
|
||||
return true
|
||||
}
|
||||
if !r.IsMember {
|
||||
return false
|
||||
}
|
||||
if r.Guild.Has(Administrator) {
|
||||
return true
|
||||
}
|
||||
@@ -178,6 +184,9 @@ func (r Resolved) Can(permission Permission) bool {
|
||||
if r.IsInstanceAdmin {
|
||||
return true
|
||||
}
|
||||
if !r.IsMember {
|
||||
return false
|
||||
}
|
||||
if r.Channel.Has(Administrator) {
|
||||
return true
|
||||
}
|
||||
@@ -197,16 +206,48 @@ func (r Resolved) CanViewChannel() bool {
|
||||
if r.IsInstanceAdmin {
|
||||
return true
|
||||
}
|
||||
if !r.IsMember {
|
||||
return false
|
||||
}
|
||||
if r.Channel.Has(Administrator) {
|
||||
return true
|
||||
}
|
||||
return r.Channel.Has(ViewChannel)
|
||||
}
|
||||
|
||||
// Names возвращает список названий установленных прав: клиенту удобнее
|
||||
// работать со списком, чем с битовой маской (AGENT.md 8.2).
|
||||
func Names(permission Permission) []string {
|
||||
names := make([]string, 0, 8)
|
||||
for bit := Permission(1); bit != 0 && bit <= Administrator; bit <<= 1 {
|
||||
if permission&bit != 0 {
|
||||
names = append(names, permissionNames[bit])
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// Parse разбирает строку с названиями прав: "VIEW_CHANNEL|SEND_MESSAGES",
|
||||
// "VIEW_CHANNEL,SEND_MESSAGES" или через пробел. Пустая строка — нет прав.
|
||||
func Parse(value string) (Permission, error) {
|
||||
normalized := strings.NewReplacer(",", " ", "|", " ").Replace(value)
|
||||
var result Permission
|
||||
for _, name := range strings.Fields(normalized) {
|
||||
permission, ok := ByName(name)
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("unknown permission %q", name)
|
||||
}
|
||||
result |= permission
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Source — данные для расчёта прав: роли сервера и роли участника.
|
||||
type Source interface {
|
||||
GuildRoles(ctx context.Context, guildID uint64) ([]RoleData, error)
|
||||
MemberRoleIDs(ctx context.Context, guildID, userID uint64) ([]uint64, error)
|
||||
// IsMember отвечает, состоит ли пользователь в сервере.
|
||||
IsMember(ctx context.Context, guildID, userID uint64) (bool, error)
|
||||
ChannelOverrides(ctx context.Context, channelID uint64) ([]OverrideData, error)
|
||||
GuildOwnerID(ctx context.Context, guildID uint64) (uint64, error)
|
||||
MemberTimeout(ctx context.Context, guildID, userID uint64) (bool, error)
|
||||
@@ -268,6 +309,19 @@ func (c *Calculator) Guild(ctx context.Context, guildID, userID uint64, instance
|
||||
}
|
||||
|
||||
resolved := Resolved{IsOwner: ownerID == userID, IsInstanceAdmin: instanceAdmin}
|
||||
resolved.IsMember = resolved.IsOwner
|
||||
if !resolved.IsMember {
|
||||
resolved.IsMember, err = c.source.IsMember(ctx, guildID, userID)
|
||||
if err != nil {
|
||||
return Resolved{}, fmt.Errorf("load guild membership: %w", err)
|
||||
}
|
||||
}
|
||||
if !resolved.IsMember {
|
||||
// Не участник: прав нет вовсе, роль @user на него не действует.
|
||||
c.cache.PutGuild(guildID, userID, resolved)
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
assigned := make(map[uint64]bool, len(memberRoleIDs))
|
||||
for _, roleID := range memberRoleIDs {
|
||||
assigned[roleID] = true
|
||||
|
||||
@@ -36,6 +36,15 @@ func (f *fakeSource) MemberRoleIDs(_ context.Context, guildID, userID uint64) ([
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakeSource) IsMember(_ context.Context, guildID, userID uint64) (bool, error) {
|
||||
if byUser, ok := f.memberRoles[guildID]; ok {
|
||||
if _, found := byUser[userID]; found {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (f *fakeSource) ChannelOverrides(_ context.Context, channelID uint64) ([]OverrideData, error) {
|
||||
return f.overrides[channelID], nil
|
||||
}
|
||||
@@ -92,6 +101,39 @@ func TestDefaultRoleProvidesBasePermissions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNonMemberHasNoPermissions(t *testing.T) {
|
||||
source := newFakeSource()
|
||||
source.roles[guildID] = []RoleData{
|
||||
{ID: defaultID, Permissions: DefaultUserPermissions, IsDefault: true, Position: 0},
|
||||
}
|
||||
source.owners[guildID] = ownerID
|
||||
source.overrides[channelID] = []OverrideData{
|
||||
{TargetType: "user", TargetID: memberID, Allow: ViewChannel | SendMessages},
|
||||
}
|
||||
calculator := NewCalculator(source)
|
||||
|
||||
// Пользователь вне сервера: роль @user и персональный оверрайд не действуют.
|
||||
resolved, err := calculator.Channel(context.Background(), guildID, channelID, memberID, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Channel: %v", err)
|
||||
}
|
||||
if resolved.IsMember {
|
||||
t.Fatal("user without membership must not be a member")
|
||||
}
|
||||
if resolved.Has(ViewGuild) || resolved.CanViewChannel() || resolved.Can(SendMessages) {
|
||||
t.Fatalf("non-member received permissions: guild=%s channel=%s", resolved.Guild, resolved.Channel)
|
||||
}
|
||||
|
||||
// Инстанс-админ обходит отсутствие участия (AGENT.md 7.19).
|
||||
admin, err := calculator.Channel(context.Background(), guildID, channelID, memberID, true)
|
||||
if err != nil {
|
||||
t.Fatalf("Channel as instance admin: %v", err)
|
||||
}
|
||||
if !admin.CanViewChannel() || !admin.Has(ManageGuild) {
|
||||
t.Fatal("instance admin must bypass membership checks")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssignedRoleAddsPermissions(t *testing.T) {
|
||||
source, calculator := fixture()
|
||||
source.memberRoles[guildID][memberID] = []uint64{moderatorRole}
|
||||
|
||||
Reference in New Issue
Block a user