feat(api): расширенная админ-панель инстанса (Фаза 7)

Статистика, карточка пользователя и работа с журналом для администратора
инстанса (AGENT.md 3.2, 7.18):

- GET /instance/stats: рост пользователей и сообщений по дням, активность,
  размеры базы и файлов, топы серверов по участникам и сообщениям;
- GET /instance/users/{id}: профиль, активные устройства, серверы с ролями,
  события безопасности и аудит по пользователю;
- DELETE /instance/users/{id}/sessions/{sid} и POST .../reset-2fa: отзыв
  одного устройства и сброс второго фактора со step-up, аудитом и записью
  в события безопасности; чужой ключ администратора не сбрасывается;
- журнал инстанса: фильтры по действию, актору, цели, серверу и датам,
  пагинация с общим числом, список действий и выгрузка CSV (лимит 5/мин);
- список серверов: поиск по названию, владелец, главный сервер, пагинация;
- миграция 00025: нормализованное название сервера `name_lower` — SQLite
  lower() не знает кириллицу, поэтому регистр приводит приложение (как для
  текста сообщений), старые записи дополняются backfill'ом при старте.
This commit is contained in:
2026-09-26 16:44:49 +03:00
parent 09b4f6878f
commit 3dc200c196
12 changed files with 2323 additions and 47 deletions
+717 -22
View File
@@ -3,15 +3,21 @@ package server
import (
"context"
"crypto/rand"
"encoding/csv"
"encoding/json"
"errors"
"log/slog"
"net/http"
"strconv"
"strings"
"time"
"github.com/danielgtaylor/huma/v2"
"github.com/go-chi/chi/v5"
"glchat/internal/bootstrap"
"glchat/internal/store"
"glchat/internal/sysinfo"
)
// instancePayload — публичная информация об инстансе (AGENT.md 6.5).
@@ -49,6 +55,9 @@ type instanceGuildPayload struct {
type instanceGuildListOutput struct {
Body struct {
Guilds []instanceGuildPayload `json:"guilds"`
// Total — сколько серверов попадает в фильтр: панель листает страницы
// (AGENT.md 7.18).
Total int `json:"total"`
}
}
@@ -260,32 +269,51 @@ func (s *Server) registerInstanceRoutes(api huma.API) {
Summary: "Все серверы инстанса",
Tags: []string{"Instance"},
Security: []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}},
}, func(ctx context.Context, _ *struct{}) (*instanceGuildListOutput, error) {
}, func(ctx context.Context, input *struct {
// Фильтры списка серверов (AGENT.md 7.18): поиск по названию, владелец,
// только главный сервер, плюс страница выдачи.
Query string `query:"q" maxLength:"64"`
OwnerID string `query:"owner_id"`
Main bool `query:"main"`
Limit int `query:"limit" default:"50" minimum:"1" maximum:"200"`
Offset int `query:"offset" default:"0" minimum:"0"`
},
) (*instanceGuildListOutput, error) {
if _, err := requireInstanceAdmin(ctx); err != nil {
return nil, err
}
guilds, err := s.store.ListAllGuilds(ctx)
var ownerID uint64
if input.OwnerID != "" {
parsed, err := parseID("owner_id", input.OwnerID)
if err != nil {
return nil, err
}
ownerID = parsed
}
rows, total, err := s.store.ListInstanceGuilds(ctx, store.InstanceGuildFilter{
Query: input.Query,
OwnerID: ownerID,
MainOnly: input.Main,
Limit: input.Limit,
Offset: input.Offset,
})
if err != nil {
return nil, humaError(err)
}
output := &instanceGuildListOutput{}
output.Body.Guilds = make([]instanceGuildPayload, 0, len(guilds))
for _, guild := range guilds {
payload := instanceGuildPayload{
ID: formatSnowflake(guild.ID),
Name: guild.Name,
OwnerID: formatSnowflake(guild.OwnerID),
IsMain: guild.IsMain,
CreatedAt: guild.CreatedAt.UTC().Format(time.RFC3339),
}
if owner, err := s.store.GetUser(ctx, guild.OwnerID); err == nil {
payload.OwnerName = owner.Username
}
if count, err := s.store.CountGuildMembers(ctx, guild.ID); err == nil {
payload.MemberCount = count
}
output.Body.Guilds = append(output.Body.Guilds, payload)
output.Body.Guilds = make([]instanceGuildPayload, 0, len(rows))
for _, row := range rows {
output.Body.Guilds = append(output.Body.Guilds, instanceGuildPayload{
ID: formatSnowflake(row.ID),
Name: row.Name,
OwnerID: formatSnowflake(row.OwnerID),
OwnerName: row.OwnerName,
IsMain: row.IsMain,
MemberCount: row.MemberCount,
CreatedAt: row.CreatedAt.UTC().Format(time.RFC3339),
})
}
output.Body.Total = total
return output, nil
})
@@ -733,18 +761,238 @@ func (s *Server) registerInstanceRoutes(api huma.API) {
Tags: []string{"Instance"},
Security: []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}},
}, func(ctx context.Context, input *struct {
Limit int `query:"limit" default:"50" minimum:"1" maximum:"200"`
// Фильтры журнала (AGENT.md 7.10, 7.18): действие, актор, цель, сервер
// и период. Даты — в формате YYYY-MM-DD (UTC).
Action string `query:"action" maxLength:"64"`
ActorID string `query:"actor_id"`
TargetID string `query:"target_id"`
GuildID string `query:"guild_id"`
Since string `query:"since" maxLength:"10"`
Until string `query:"until" maxLength:"10"`
Limit int `query:"limit" default:"50" minimum:"1" maximum:"200"`
Offset int `query:"offset" default:"0" minimum:"0"`
},
) (*auditListOutput, error) {
) (*instanceAuditOutput, error) {
if _, err := requireInstanceAdmin(ctx); err != nil {
return nil, err
}
entries, err := s.store.ListInstanceAudit(ctx, input.Limit)
filter, err := auditFilterFromQuery(auditQueryParams{
Action: input.Action, ActorID: input.ActorID, TargetID: input.TargetID,
GuildID: input.GuildID, Since: input.Since, Until: input.Until,
Limit: input.Limit, Offset: input.Offset,
})
if err != nil {
return nil, err
}
entries, total, err := s.store.ListInstanceAuditFiltered(ctx, filter)
if err != nil {
return nil, humaError(err)
}
output := &auditListOutput{}
output := &instanceAuditOutput{}
output.Body.Entries = auditPayloads(entries)
output.Body.Total = total
return output, nil
})
huma.Register(api, huma.Operation{
OperationID: "listInstanceAuditActions",
Method: http.MethodGet,
Path: "/instance/audit/actions",
Summary: "Действия, встречающиеся в журнале инстанса",
Tags: []string{"Instance"},
Security: []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}},
}, func(ctx context.Context, _ *struct{}) (*instanceAuditActionsOutput, error) {
if _, err := requireInstanceAdmin(ctx); err != nil {
return nil, err
}
actions, err := s.store.ListInstanceAuditActions(ctx)
if err != nil {
return nil, humaError(err)
}
output := &instanceAuditActionsOutput{}
output.Body.Actions = actions
return output, nil
})
huma.Register(api, huma.Operation{
OperationID: "getInstanceStats",
Method: http.MethodGet,
Path: "/instance/stats",
Summary: "Статистика инстанса",
Tags: []string{"Instance"},
Security: []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}},
}, func(ctx context.Context, _ *struct{}) (*instanceStatsOutput, error) {
if _, err := requireInstanceAdmin(ctx); err != nil {
return nil, err
}
stats, err := s.store.InstanceStats(ctx)
if err != nil {
return nil, humaError(err)
}
output := &instanceStatsOutput{}
output.Body.Stats = s.instanceStatsPayload(stats)
return output, nil
})
huma.Register(api, huma.Operation{
OperationID: "getInstanceUser",
Method: http.MethodGet,
Path: "/instance/users/{user_id}",
Summary: "Карточка пользователя для админ-панели",
Tags: []string{"Instance"},
Security: []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}},
}, func(ctx context.Context, input *struct {
UserID string `path:"user_id"`
},
) (*instanceUserCardOutput, error) {
if _, err := requireInstanceAdmin(ctx); err != nil {
return nil, err
}
targetID, err := parseID("user_id", input.UserID)
if err != nil {
return nil, err
}
target, err := s.store.GetUser(ctx, targetID)
if err != nil {
return nil, humaError(err)
}
card, err := s.instanceUserCard(ctx, target)
if err != nil {
return nil, err
}
return card, nil
})
huma.Register(api, huma.Operation{
OperationID: "adminRevokeUserSession",
Method: http.MethodDelete,
Path: "/instance/users/{user_id}/sessions/{session_id}",
Summary: "Отозвать одно устройство пользователя",
Tags: []string{"Instance"},
Security: []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}},
}, func(ctx context.Context, input *struct {
UserID string `path:"user_id"`
SessionID string `path:"session_id"`
Body struct {
// StepUpPassword подтверждает действие: отзыв устройства —
// чувствительная операция (AGENT.md 7.1, 7.18).
StepUpPassword string `json:"step_up_password,omitempty"`
StepUpTOTP string `json:"step_up_totp,omitempty"`
}
},
) (*okOutput, error) {
admin, session, err := requireUser(ctx)
if err != nil {
return nil, err
}
if !admin.IsInstanceAdmin {
return nil, humaErrorStatus(http.StatusForbidden, "instance.admin_required", "instance administrator rights required")
}
if err := s.auth.RequireStepUp(ctx, admin, session, input.Body.StepUpPassword, input.Body.StepUpTOTP); err != nil {
return nil, humaError(err)
}
targetID, err := parseID("user_id", input.UserID)
if err != nil {
return nil, err
}
sessionID, err := parseID("session_id", input.SessionID)
if err != nil {
return nil, err
}
targetSession, err := s.store.UserSession(ctx, targetID, sessionID)
if err != nil {
return nil, humaError(err)
}
deleted, err := s.store.DeleteUserSession(ctx, targetID, sessionID)
if err != nil {
return nil, humaError(err)
}
if !deleted {
return nil, humaErrorStatus(http.StatusNotFound, "not_found", "session not found")
}
// Закрываем только это соединение: остальные устройства пользователя
// продолжают работу (AGENT.md 11.6).
if s.gateway != nil {
s.gateway.InvalidateSession(targetID, targetSession.TokenHash, "session_revoked")
}
s.recordSecurityEvent(ctx, &targetID, "session_revoked", session, map[string]any{
"by": "instance_admin",
"session_id": formatSnowflake(sessionID),
})
s.recordAudit(ctx, admin, 0, "instance.user_session_revoke", "user", &targetID, "")
return newOKOutput(), nil
})
huma.Register(api, huma.Operation{
OperationID: "adminResetUser2FA",
Method: http.MethodPost,
Path: "/instance/users/{user_id}/reset-2fa",
Summary: "Сбросить второй фактор пользователя",
Tags: []string{"Instance"},
Security: []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}},
}, func(ctx context.Context, input *struct {
UserID string `path:"user_id"`
Body struct {
// Сброс второго фактора — восстановление доступа: без step-up
// администратора это был бы готовый способ захвата аккаунта.
StepUpPassword string `json:"step_up_password,omitempty"`
StepUpTOTP string `json:"step_up_totp,omitempty"`
}
},
) (*adminReset2FAOutput, error) {
admin, session, err := requireUser(ctx)
if err != nil {
return nil, err
}
if !admin.IsInstanceAdmin {
return nil, humaErrorStatus(http.StatusForbidden, "instance.admin_required", "instance administrator rights required")
}
if err := s.auth.RequireStepUp(ctx, admin, session, input.Body.StepUpPassword, input.Body.StepUpTOTP); err != nil {
return nil, humaError(err)
}
targetID, err := parseID("user_id", input.UserID)
if err != nil {
return nil, err
}
if targetID == admin.ID {
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "use the security settings to reset your own second factor")
}
target, err := s.store.GetUser(ctx, targetID)
if err != nil {
return nil, humaError(err)
}
// У администратора инстанса второй фактор обязателен (AGENT.md 7.19):
// сброс чужого ключа стал бы обходом этого правила.
if target.IsInstanceAdmin {
return nil, humaErrorStatus(http.StatusForbidden, "instance.admin_protected", "instance administrator second factor cannot be reset")
}
secret, err := s.store.GetTOTPSecret(ctx, targetID)
if errors.Is(err, store.ErrNotFound) || (err == nil && !secret.Enabled) {
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "two-factor authentication is not enabled")
}
if err != nil {
return nil, humaError(err)
}
if err := s.store.DeleteTOTPSecret(ctx, targetID); err != nil {
return nil, humaError(err)
}
// После сброса второго фактора сессии отзываются: владелец входит
// заново паролем и настраивает новый ключ.
revoked, err := s.store.RevokeUserSessions(ctx, targetID)
if err != nil {
return nil, humaError(err)
}
if s.gateway != nil {
s.gateway.InvalidateUser(targetID, "2fa_reset")
}
s.recordSecurityEvent(ctx, &targetID, "2fa_change", session, map[string]any{
"action": "reset",
"by": "instance_admin",
})
s.recordAudit(ctx, admin, 0, "instance.user_2fa_reset", "user", &targetID, "")
output := &adminReset2FAOutput{}
output.Body.UserID = formatSnowflake(targetID)
output.Body.SessionsRevoked = revoked
return output, nil
})
}
@@ -829,3 +1077,450 @@ func newTemporaryPassword() (string, error) {
}
return builder.String(), nil
}
// instanceAuditOutput — страница журнала инстанса с общим числом записей под
// фильтр: панели нужна пагинация (AGENT.md 7.18).
type instanceAuditOutput struct {
Body struct {
Entries []auditEntryPayload `json:"entries"`
Total int `json:"total"`
}
}
type instanceAuditActionsOutput struct {
Body struct {
Actions []string `json:"actions"`
}
}
// dailyCountPayload — точка роста для графика статистики.
type dailyCountPayload struct {
Date string `json:"date"`
Count int `json:"count"`
}
type instanceGuildStatPayload struct {
ID string `json:"id"`
Name string `json:"name"`
Members int `json:"members"`
Messages int `json:"messages"`
}
type instanceStatsPayload struct {
Users struct {
Total int `json:"total"`
Admins int `json:"admins"`
Banned int `json:"banned"`
New7Days int `json:"new_7_days"`
New30Days int `json:"new_30_days"`
Active24h int `json:"active_24h"`
Daily []dailyCountPayload `json:"daily"`
} `json:"users"`
Messages struct {
Total int `json:"total"`
Today int `json:"today"`
Week int `json:"week"`
Month int `json:"month"`
Daily []dailyCountPayload `json:"daily"`
} `json:"messages"`
Files struct {
Count int `json:"count"`
Bytes int64 `json:"bytes"`
} `json:"files"`
Guilds int `json:"guilds"`
Invites int `json:"invites"`
Bans int `json:"bans"`
DatabaseBytes int64 `json:"database_bytes"`
StorageBytes int64 `json:"storage_bytes"`
TopGuilds []instanceGuildStatPayload `json:"top_guilds"`
BusiestGuilds []instanceGuildStatPayload `json:"busiest_guilds"`
}
type instanceStatsOutput struct {
Body struct {
Stats instanceStatsPayload `json:"stats"`
}
}
type instanceSessionPayload struct {
ID string `json:"id"`
UserAgent string `json:"user_agent,omitempty"`
IP string `json:"ip,omitempty"`
CreatedAt string `json:"created_at"`
LastSeen string `json:"last_seen"`
ExpiresAt string `json:"expires_at"`
// SteppedUp — сессия недавно подтверждала пароль: видно, какие устройства
// администратор может отозвать без последствий для остальных.
SteppedUp bool `json:"stepped_up"`
}
type instanceMembershipRolePayload struct {
ID string `json:"id"`
Name string `json:"name"`
Color int64 `json:"color"`
}
type instanceMembershipPayload struct {
GuildID string `json:"guild_id"`
GuildName string `json:"guild_name"`
Nickname string `json:"nickname,omitempty"`
JoinedAt string `json:"joined_at"`
Roles []instanceMembershipRolePayload `json:"roles"`
}
// instanceUserCardOutput — карточка пользователя: профиль, устройства,
// серверы с ролями, события безопасности и аудит по нему (AGENT.md 7.18).
type instanceUserCardOutput struct {
Body struct {
User instanceUserPayload `json:"user"`
TOTPEnabled bool `json:"totp_enabled"`
Passkeys int `json:"passkeys"`
Sessions []instanceSessionPayload `json:"sessions"`
Guilds []instanceMembershipPayload `json:"guilds"`
SecurityEvents []securityEventPayload `json:"security_events"`
Audit []auditEntryPayload `json:"audit"`
AuditTotal int `json:"audit_total"`
}
}
type adminReset2FAOutput struct {
Body struct {
UserID string `json:"user_id"`
SessionsRevoked int64 `json:"sessions_revoked"`
}
}
// securityEventsLimit и userAuditLimit — сколько событий и записей аудита
// показывать в карточке пользователя: остальное доступно в разделе «Аудит».
const (
securityEventsLimit = 20
userAuditLimit = 20
)
// auditQueryParams — фильтры журнала, как они приходят из строки запроса.
// Один разбор используется и JSON-ручкой (huma), и экспортом CSV.
type auditQueryParams struct {
Action string
ActorID string
TargetID string
GuildID string
Since string
Until string
Limit int
Offset int
}
// auditFilterFromQuery собирает фильтр журнала и разбирает даты (AGENT.md 7.10).
func auditFilterFromQuery(params auditQueryParams) (store.InstanceAuditFilter, error) {
filter := store.InstanceAuditFilter{
Action: strings.TrimSpace(params.Action),
Limit: params.Limit,
Offset: params.Offset,
}
for _, item := range []struct {
raw string
name string
into *uint64
}{
{params.ActorID, "actor_id", &filter.ActorID},
{params.TargetID, "target_id", &filter.TargetID},
{params.GuildID, "guild_id", &filter.GuildID},
} {
if strings.TrimSpace(item.raw) == "" {
continue
}
value, err := parseID(item.name, strings.TrimSpace(item.raw))
if err != nil {
return store.InstanceAuditFilter{}, err
}
*item.into = value
}
since, err := parseAuditDate(params.Since, false)
if err != nil {
return store.InstanceAuditFilter{}, err
}
until, err := parseAuditDate(params.Until, true)
if err != nil {
return store.InstanceAuditFilter{}, err
}
filter.Since = since
filter.Until = until
return filter, nil
}
// parseAuditDate читает дату фильтра в формате YYYY-MM-DD (UTC). Для «по дату»
// берётся конец дня: иначе запись за 20 сентября не попала бы в выборку
// «по 20 сентября» (время в базе хранится с точностью до миллисекунд).
func parseAuditDate(value string, endOfDay bool) (*time.Time, error) {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return nil, nil
}
parsed, err := time.Parse("2006-01-02", trimmed)
if err != nil {
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "date must be YYYY-MM-DD")
}
if endOfDay {
parsed = parsed.Add(24*time.Hour - time.Millisecond)
}
return &parsed, nil
}
// instanceStatsPayload дополняет выборку из store размерами хранилища: размер
// базы и файлов виден только серверу (AGENT.md 7.18).
func (s *Server) instanceStatsPayload(stats *store.InstanceStats) instanceStatsPayload {
payload := instanceStatsPayload{
Guilds: stats.Guilds,
Invites: stats.Invites,
Bans: stats.Bans,
}
payload.Users.Total = stats.Users.Total
payload.Users.Admins = stats.Users.Admins
payload.Users.Banned = stats.Users.Banned
payload.Users.New7Days = stats.Users.New7Days
payload.Users.New30Days = stats.Users.New30Days
payload.Users.Active24h = stats.Users.Active24h
payload.Users.Daily = dailyCountsPayload(stats.Users.Daily)
payload.Messages.Total = stats.Messages.Total
payload.Messages.Today = stats.Messages.Today
payload.Messages.Week = stats.Messages.Week
payload.Messages.Month = stats.Messages.Month
payload.Messages.Daily = dailyCountsPayload(stats.Messages.Daily)
payload.Files.Count = stats.Files.Count
payload.Files.Bytes = stats.Files.Bytes
payload.TopGuilds = guildStatsPayload(stats.TopGuilds)
payload.BusiestGuilds = guildStatsPayload(stats.BusiestGuilds)
database := sysinfo.FileBytes(s.databasePath())
database += sysinfo.FileBytes(s.databasePath() + "-wal")
payload.DatabaseBytes = database
payload.StorageBytes = database + stats.Files.Bytes
return payload
}
func dailyCountsPayload(counts []store.DailyCount) []dailyCountPayload {
payload := make([]dailyCountPayload, 0, len(counts))
for _, item := range counts {
payload = append(payload, dailyCountPayload{Date: item.Date, Count: item.Count})
}
return payload
}
func guildStatsPayload(guilds []store.InstanceGuildStat) []instanceGuildStatPayload {
payload := make([]instanceGuildStatPayload, 0, len(guilds))
for _, item := range guilds {
payload = append(payload, instanceGuildStatPayload{
ID: formatSnowflake(item.ID), Name: item.Name,
Members: item.Members, Messages: item.Messages,
})
}
return payload
}
// instanceUserCard собирает карточку пользователя для админ-панели: профиль,
// активные устройства, серверы с ролями, события безопасности и аудит, где он
// актор или цель (AGENT.md 7.18, 7.19).
func (s *Server) instanceUserCard(ctx context.Context, target *store.User) (*instanceUserCardOutput, error) {
output := &instanceUserCardOutput{}
output.Body.User = instanceUserPayloadFrom(*target)
output.Body.Sessions = []instanceSessionPayload{}
output.Body.Guilds = []instanceMembershipPayload{}
output.Body.SecurityEvents = []securityEventPayload{}
sessions, err := s.store.ListSessions(ctx, target.ID)
if err != nil {
return nil, humaError(err)
}
for _, session := range sessions {
output.Body.Sessions = append(output.Body.Sessions, instanceSessionPayload{
ID: formatSnowflake(session.ID),
UserAgent: session.UserAgent,
IP: session.IP,
CreatedAt: session.CreatedAt.UTC().Format(time.RFC3339),
LastSeen: session.LastSeen.UTC().Format(time.RFC3339),
ExpiresAt: session.ExpiresAt.UTC().Format(time.RFC3339),
SteppedUp: session.SteppedUp(time.Now().UTC()),
})
}
memberships, err := s.store.ListUserGuildMemberships(ctx, target.ID)
if err != nil {
return nil, humaError(err)
}
for _, membership := range memberships {
item := instanceMembershipPayload{
GuildID: formatSnowflake(membership.GuildID),
GuildName: membership.GuildName,
Nickname: membership.Nickname,
JoinedAt: membership.JoinedAt.UTC().Format(time.RFC3339),
Roles: []instanceMembershipRolePayload{},
}
for _, role := range membership.Roles {
item.Roles = append(item.Roles, instanceMembershipRolePayload{
ID: formatSnowflake(role.ID), Name: role.Name, Color: role.Color,
})
}
output.Body.Guilds = append(output.Body.Guilds, item)
}
events, err := s.store.ListSecurityEvents(ctx, target.ID, securityEventsLimit)
if err != nil {
return nil, humaError(err)
}
for _, event := range events {
output.Body.SecurityEvents = append(output.Body.SecurityEvents, securityEventPayload{
ID: formatSnowflake(event.ID),
Type: event.Type,
IP: event.IP,
UserAgent: event.UserAgent,
Metadata: event.Metadata,
CreatedAt: event.CreatedAt.UTC().Format(time.RFC3339),
})
}
entries, total, err := s.store.ListUserAudit(ctx, target.ID, userAuditLimit, 0)
if err != nil {
return nil, humaError(err)
}
output.Body.Audit = auditPayloads(entries)
output.Body.AuditTotal = total
passkeys, err := s.store.CountPasskeys(ctx, target.ID)
if err != nil {
return nil, humaError(err)
}
output.Body.Passkeys = passkeys
if secret, err := s.store.GetTOTPSecret(ctx, target.ID); err == nil {
output.Body.TOTPEnabled = secret.Enabled
} else if !errors.Is(err, store.ErrNotFound) {
return nil, humaError(err)
}
return output, nil
}
// registerInstanceExportRoutes описывает выгрузку журнала инстанса: ответ —
// CSV, а не JSON, поэтому ручка живёт на роутере chi, а не в huma.
func (s *Server) registerInstanceExportRoutes(router chi.Router) {
router.Get("/instance/audit/export", s.handleInstanceAuditExport)
}
// handleInstanceAuditExport отдаёт журнал инстанса файлом CSV с теми же
// фильтрами, что и раздел «Аудит» (AGENT.md 7.18). Предел выгрузки — 5000
// строк, иначе один запрос вычитывал бы весь журнал в память.
func (s *Server) handleInstanceAuditExport(w http.ResponseWriter, r *http.Request) {
user, _, ok := s.authenticate(w, r)
if !ok {
return
}
if !user.IsInstanceAdmin {
httpxWriteJSONError(w, http.StatusForbidden, "instance.admin_required", "instance administrator rights required")
return
}
if allowed, retryAfter := s.auditExportLimiter.Allow("audit-export:" + formatSnowflake(user.ID)); !allowed {
writeHumaAPIError(w, rateLimitedError(retryAfter))
return
}
query := r.URL.Query()
filter, err := auditFilterFromQuery(auditQueryParams{
Action: query.Get("action"),
ActorID: query.Get("actor_id"),
TargetID: query.Get("target_id"),
GuildID: query.Get("guild_id"),
Since: query.Get("since"),
Until: query.Get("until"),
Limit: store.MaxInstanceAuditLimit,
})
if err != nil {
writeHumaAPIError(w, err)
return
}
entries, _, err := s.store.ListInstanceAuditFiltered(r.Context(), filter)
if err != nil {
writeHumaAPIError(w, humaError(err))
return
}
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
w.Header().Set("Content-Disposition",
`attachment; filename="audit-`+time.Now().UTC().Format("20060102-150405")+`.csv"`)
w.Header().Set("X-Content-Type-Options", "nosniff")
writer := csv.NewWriter(w)
write := func(row []string) {
safe := make([]string, 0, len(row))
for _, value := range row {
safe = append(safe, csvSafe(value))
}
_ = writer.Write(safe)
}
write([]string{
"id", "created_at", "action", "actor_id", "actor_instance_admin",
"target_type", "target_id", "guild_id", "reason", "changes",
})
for _, entry := range entries {
write([]string{
formatSnowflake(entry.ID),
entry.CreatedAt.UTC().Format(time.RFC3339),
entry.Action,
optionalSnowflake(entry.ActorID),
strconv.FormatBool(entry.ActorInstanceAdmin),
entry.TargetType,
optionalSnowflake(entry.TargetID),
optionalSnowflake(entry.GuildID),
entry.Reason,
string(entry.Changes),
})
}
writer.Flush()
if err := writer.Error(); err != nil {
s.logger.WarnContext(r.Context(), "audit export failed", slog.Any("error", err))
}
s.recordAudit(r.Context(), user, 0, "instance.audit_export", "instance", nil, "")
}
// csvSafe защищает выгрузку от формул Excel: значения, начинающиеся с «=»,
// «+», «-» или «@», таблица воспринимает как формулу (CSV injection).
func csvSafe(value string) string {
if value == "" {
return value
}
switch value[0] {
case '=', '+', '-', '@':
return "'" + value
default:
return value
}
}
// optionalSnowflake печатает необязательный идентификатор.
func optionalSnowflake(id *uint64) string {
if id == nil {
return ""
}
return formatSnowflake(*id)
}
// recordSecurityEvent пишет событие безопасности по действию администратора
// инстанса: владелец аккаунта должен видеть в разделе безопасности, что его
// устройство отозвали или сбросили второй фактор (AGENT.md 7.1, 7.19). Ошибка
// записи не ломает основное действие.
func (s *Server) recordSecurityEvent(
ctx context.Context,
userID *uint64,
eventType string,
session *store.Session,
metadata map[string]any,
) {
encoded := "{}"
if len(metadata) > 0 {
if raw, err := json.Marshal(metadata); err == nil {
encoded = string(raw)
}
}
var ip, userAgent string
if session != nil {
ip, userAgent = session.IP, session.UserAgent
}
if err := s.store.RecordSecurityEvent(ctx, userID, eventType, ip, userAgent, encoded); err != nil {
s.logger.WarnContext(ctx, "failed to record security event", slog.Any("error", err))
}
}
+9 -6
View File
@@ -2,6 +2,7 @@ package server
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"strings"
@@ -147,13 +148,15 @@ type onboardingInput struct {
}
}
// securityEventPayload — событие безопасности без служебных метаданных.
// securityEventPayload — событие безопасности владельца аккаунта; карточка
// пользователя в админ-панели дополняет его метаданными (AGENT.md 7.18).
type securityEventPayload struct {
ID string `json:"id"`
Type string `json:"type"`
IP string `json:"ip,omitempty"`
UserAgent string `json:"user_agent,omitempty"`
CreatedAt string `json:"created_at"`
ID string `json:"id"`
Type string `json:"type"`
IP string `json:"ip,omitempty"`
UserAgent string `json:"user_agent,omitempty"`
CreatedAt string `json:"created_at"`
Metadata json.RawMessage `json:"metadata,omitempty"`
}
type securityEventListOutput struct {
+583
View File
@@ -0,0 +1,583 @@
package server
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// Тесты расширенной админ-панели инстанса (AGENT.md 3.2, 7.18, 11.5):
// обычный пользователь не видит разделы, администратор получает данные,
// чувствительные действия требуют step-up и попадают в аудит.
// instanceGet вызывает ручку панели администратором и проверяет статус.
func instanceGet(t *testing.T, srv *Server, cookie *http.Cookie, path string, want int) *httptest.ResponseRecorder {
t.Helper()
rec := doJSON(t, srv, http.MethodGet, path, "", cookie)
if rec.Code != want {
t.Fatalf("GET %s = %d, want %d (body: %s)", path, rec.Code, want, rec.Body.String())
}
return rec
}
// TestInstancePanelRequiresAdmin: разделы расширенной панели закрыты для
// обычного пользователя (AGENT.md 7.18, 11.5).
func TestInstancePanelRequiresAdmin(t *testing.T) {
srv, _ := newTestServer(t)
userCookie := registerAndLogin(t, srv, "panel_user", "panel-user@example.com")
target, err := srv.auth.UserByEmail(t.Context(), "panel-user@example.com")
if err != nil {
t.Fatalf("UserByEmail: %v", err)
}
targetPath := formatSnowflake(target.ID)
for _, path := range []string{
"/api/v1/instance/stats",
"/api/v1/instance/audit",
"/api/v1/instance/audit/actions",
"/api/v1/instance/audit/export",
"/api/v1/instance/users/" + targetPath,
"/api/v1/instance/guilds?limit=10",
} {
rec := doJSON(t, srv, http.MethodGet, path, "", userCookie)
if rec.Code != http.StatusForbidden {
t.Fatalf("GET %s обычным пользователем = %d, want 403", path, rec.Code)
}
}
}
// TestInstanceStatsVisibility: статистика инстанса считает пользователей,
// сообщения и серверы, а обычному пользователю недоступна (AGENT.md 7.18).
func TestInstanceStatsVisibility(t *testing.T) {
srv, _ := newTestServer(t)
adminCookie := registerAndLogin(t, srv, "stats_admin", "stats-admin@example.com")
promoteAdmin(t, srv, "stats-admin@example.com")
registerAndLogin(t, srv, "stats_plain", "stats-plain@example.com")
rec := instanceGet(t, srv, adminCookie, "/api/v1/instance/stats", http.StatusOK)
stats := decodeResponse[struct {
Stats struct {
Users struct {
Total int `json:"total"`
Admins int `json:"admins"`
New7Days int `json:"new_7_days"`
Daily []struct {
Date string `json:"date"`
Count int `json:"count"`
} `json:"daily"`
} `json:"users"`
Messages struct {
Total int `json:"total"`
Today int `json:"today"`
} `json:"messages"`
Guilds int `json:"guilds"`
DatabaseBytes int64 `json:"database_bytes"`
StorageBytes int64 `json:"storage_bytes"`
} `json:"stats"`
}](t, rec)
if stats.Stats.Users.Total < 2 || stats.Stats.Users.New7Days < 2 {
t.Fatalf("пользователи в статистике: %+v", stats.Stats.Users)
}
if stats.Stats.Users.Admins != 1 {
t.Fatalf("администраторов = %d, want 1", stats.Stats.Users.Admins)
}
if len(stats.Stats.Users.Daily) == 0 {
t.Fatal("рост пользователей по дням пуст")
}
if stats.Stats.DatabaseBytes <= 0 || stats.Stats.StorageBytes < stats.Stats.DatabaseBytes {
t.Fatalf("размеры данных: %+v", stats.Stats)
}
}
// TestInstanceAuditFiltersAndExport: фильтры журнала, пагинация и выгрузка CSV
// (AGENT.md 7.10, 7.18).
func TestInstanceAuditFiltersAndExport(t *testing.T) {
srv, _ := newTestServer(t)
adminCookie := registerAndLogin(t, srv, "audit_admin", "audit-admin@example.com")
promoteAdmin(t, srv, "audit-admin@example.com")
registerAndLogin(t, srv, "audit_target", "audit-target@example.com")
target, err := srv.auth.UserByEmail(t.Context(), "audit-target@example.com")
if err != nil {
t.Fatalf("UserByEmail: %v", err)
}
targetID := formatSnowflake(target.ID)
// Действия администратора создают записи журнала.
if rec := doJSON(t, srv, http.MethodPost, "/api/v1/instance/users/"+targetID+"/logout", "", adminCookie); rec.Code != http.StatusOK {
t.Fatalf("logout = %d (%s)", rec.Code, rec.Body.String())
}
if rec := doJSON(t, srv, http.MethodPatch, "/api/v1/instance/settings",
`{"max_message_length":4001}`, adminCookie); rec.Code != http.StatusOK {
t.Fatalf("settings = %d (%s)", rec.Code, rec.Body.String())
}
listAudit := func(query string) ([]string, int) {
t.Helper()
path := "/api/v1/instance/audit"
if query != "" {
path += "?" + query
}
rec := instanceGet(t, srv, adminCookie, path, http.StatusOK)
payload := decodeResponse[struct {
Entries []struct {
Action string `json:"action"`
TargetID string `json:"target_id"`
} `json:"entries"`
Total int `json:"total"`
}](t, rec)
actions := make([]string, 0, len(payload.Entries))
for _, entry := range payload.Entries {
actions = append(actions, entry.Action)
}
return actions, payload.Total
}
all, total := listAudit("")
if total < 2 || len(all) < 2 {
t.Fatalf("журнал пуст: %v (total %d)", all, total)
}
byAction, total := listAudit("action=instance.user_logout")
if total != 1 || len(byAction) != 1 || byAction[0] != "instance.user_logout" {
t.Fatalf("фильтр по действию: %v (total %d)", byAction, total)
}
byTarget, total := listAudit("target_id=" + targetID)
if total != 1 || len(byTarget) != 1 {
t.Fatalf("фильтр по цели: %v (total %d)", byTarget, total)
}
actorID := ""
{
admin, err := srv.auth.UserByEmail(t.Context(), "audit-admin@example.com")
if err != nil {
t.Fatalf("UserByEmail: %v", err)
}
actorID = formatSnowflake(admin.ID)
}
byActor, total := listAudit("actor_id=" + actorID)
if total < 2 || len(byActor) < 2 {
t.Fatalf("фильтр по актору: %v (total %d)", byActor, total)
}
today := time.Now().UTC().Format("2006-01-02")
byDate, total := listAudit("since=" + today + "&until=" + today)
if total < 2 {
t.Fatalf("фильтр по дате: %v (total %d)", byDate, total)
}
if _, total := listAudit("since=2030-01-01"); total != 0 {
t.Fatalf("фильтр «в будущем» вернул %d записей", total)
}
page1, _ := listAudit("limit=1&offset=0")
page2, _ := listAudit("limit=1&offset=1")
if len(page1) != 1 || len(page2) != 1 || page1[0] == page2[0] {
t.Fatalf("пагинация журнала: %v / %v", page1, page2)
}
// Неверная дата — понятная ошибка валидации, а не пустой журнал.
bad := doJSON(t, srv, http.MethodGet, "/api/v1/instance/audit?since=вчера", "", adminCookie)
if bad.Code != http.StatusUnprocessableEntity {
t.Fatalf("неверная дата = %d, want 422", bad.Code)
}
// Список действий для фильтра.
actionsRec := instanceGet(t, srv, adminCookie, "/api/v1/instance/audit/actions", http.StatusOK)
actions := decodeResponse[struct {
Actions []string `json:"actions"`
}](t, actionsRec)
foundLogout := false
for _, action := range actions.Actions {
if action == "instance.user_logout" {
foundLogout = true
}
}
if !foundLogout {
t.Fatalf("список действий не содержит instance.user_logout: %v", actions.Actions)
}
// Экспорт CSV: тот же фильтр, заголовок и строки.
export := doJSON(t, srv, http.MethodGet,
"/api/v1/instance/audit/export?action=instance.user_logout", "", adminCookie)
if export.Code != http.StatusOK {
t.Fatalf("экспорт = %d (%s)", export.Code, export.Body.String())
}
if contentType := export.Header().Get("Content-Type"); !strings.HasPrefix(contentType, "text/csv") {
t.Fatalf("Content-Type экспорта = %q", contentType)
}
body := export.Body.String()
if !strings.Contains(body, "id,created_at,action") || !strings.Contains(body, "instance.user_logout") {
t.Fatalf("тело экспорта: %s", body)
}
if strings.Contains(body, "instance.settings_update") {
t.Fatal("экспорт не учёл фильтр по действию")
}
// Экспорт — тоже событие журнала.
_, total = listAudit("action=instance.audit_export")
if total != 1 {
t.Fatalf("экспорт не записан в аудит: total %d", total)
}
}
// TestInstanceUserCardShowsDetails: карточка пользователя отдаёт устройства,
// серверы с ролями, события безопасности и аудит по нему (AGENT.md 7.18).
func TestInstanceUserCardShowsDetails(t *testing.T) {
srv, _ := newTestServer(t)
adminCookie := registerAndLogin(t, srv, "card_admin", "card-admin@example.com")
promoteAdmin(t, srv, "card-admin@example.com")
targetCookie := registerAndLogin(t, srv, "card_target", "card-target@example.com")
target, err := srv.auth.UserByEmail(t.Context(), "card-target@example.com")
if err != nil {
t.Fatalf("UserByEmail: %v", err)
}
targetID := formatSnowflake(target.ID)
// У цели есть сервер (создаётся вместе с ролями по умолчанию) — карточка
// должна показать участие и роли.
created := doJSON(t, srv, http.MethodPost, "/api/v1/guilds", `{"name":"Карточка"}`, targetCookie)
if created.Code != http.StatusOK {
t.Fatalf("создать сервер = %d (%s)", created.Code, created.Body.String())
}
rec := instanceGet(t, srv, adminCookie, "/api/v1/instance/users/"+targetID, http.StatusOK)
card := decodeResponse[struct {
User struct {
ID string `json:"id"`
Username string `json:"username"`
} `json:"user"`
TOTPEnabled bool `json:"totp_enabled"`
Passkeys int `json:"passkeys"`
Sessions []struct {
ID string `json:"id"`
UserAgent string `json:"user_agent"`
} `json:"sessions"`
Guilds []struct {
GuildName string `json:"guild_name"`
Roles []struct {
Name string `json:"name"`
} `json:"roles"`
} `json:"guilds"`
SecurityEvents []struct {
Type string `json:"type"`
} `json:"security_events"`
Audit []any `json:"audit"`
AuditTotal int `json:"audit_total"`
}](t, rec)
if card.User.ID != targetID || card.User.Username != "card_target" {
t.Fatalf("профиль в карточке: %+v", card.User)
}
if len(card.Sessions) != 1 || card.Sessions[0].ID == "" {
t.Fatalf("устройства в карточке: %+v", card.Sessions)
}
if len(card.SecurityEvents) == 0 {
t.Fatal("события безопасности не попали в карточку")
}
if card.TOTPEnabled || card.Passkeys != 0 {
t.Fatalf("второй фактор/ключи: %v/%d", card.TOTPEnabled, card.Passkeys)
}
if len(card.Guilds) != 1 || card.Guilds[0].GuildName != "Карточка" {
t.Fatalf("серверы в карточке: %+v", card.Guilds)
}
if len(card.Guilds[0].Roles) == 0 {
t.Fatalf("роли в карточке пусты: %+v", card.Guilds[0])
}
// Несуществующий пользователь — 404, а не пустая карточка.
missing := doJSON(t, srv, http.MethodGet, "/api/v1/instance/users/999999999", "", adminCookie)
if missing.Code != http.StatusNotFound {
t.Fatalf("карточка несуществующего = %d, want 404", missing.Code)
}
}
// TestInstanceRevokeSession: отзыв одного устройства требует step-up, попадает
// в аудит и в события безопасности, второе устройство продолжает работать
// (AGENT.md 7.1, 7.18, 11.6).
func TestInstanceRevokeSession(t *testing.T) {
srv, _ := newTestServer(t)
adminCookie := registerAndLogin(t, srv, "revoke_admin", "revoke-admin@example.com")
promoteAdmin(t, srv, "revoke-admin@example.com")
firstCookie := registerAndLogin(t, srv, "revoke_target", "revoke-target@example.com")
// Второе устройство: обычный вход тем же паролем.
second := doJSON(t, srv, http.MethodPost, "/api/v1/auth/login",
`{"email":"revoke-target@example.com","password":"correct-horse-battery"}`)
if second.Code != http.StatusOK {
t.Fatalf("второй вход = %d (%s)", second.Code, second.Body.String())
}
secondCookie := second.Result().Cookies()[0]
target, err := srv.auth.UserByEmail(t.Context(), "revoke-target@example.com")
if err != nil {
t.Fatalf("UserByEmail: %v", err)
}
targetID := formatSnowflake(target.ID)
cardRec := instanceGet(t, srv, adminCookie, "/api/v1/instance/users/"+targetID, http.StatusOK)
card := decodeResponse[struct {
Sessions []struct {
ID string `json:"id"`
} `json:"sessions"`
}](t, cardRec)
if len(card.Sessions) != 2 {
t.Fatalf("устройств у цели = %d, want 2", len(card.Sessions))
}
// Определяем, какая из сессий принадлежит второму устройству: отзываем
// первую и проверяем, что хотя бы одно устройство продолжает работать.
revokePath := "/api/v1/instance/users/" + targetID + "/sessions/" + card.Sessions[0].ID
noStepUp := doJSON(t, srv, http.MethodDelete, revokePath, `{}`, adminCookie)
if noStepUp.Code != http.StatusForbidden {
t.Fatalf("отзыв без step-up = %d, want 403", noStepUp.Code)
}
if code := errorCodeOf(t, noStepUp); code != "auth.step_up_required" {
t.Fatalf("код отказа без step-up = %q", code)
}
ok := doJSON(t, srv, http.MethodDelete, revokePath,
`{"step_up_password":"correct-horse-battery"}`, adminCookie)
if ok.Code != http.StatusOK {
t.Fatalf("отзыв устройства = %d (%s)", ok.Code, ok.Body.String())
}
// Отозванное устройство теряет доступ, второе продолжает работать.
alive := 0
for _, cookie := range []*http.Cookie{firstCookie, secondCookie} {
rec := doJSON(t, srv, http.MethodGet, "/api/v1/users/@me", "", cookie)
if rec.Code == http.StatusOK {
alive++
}
}
if alive != 1 {
t.Fatalf("после отзыва работает устройств: %d, want 1", alive)
}
// Повторный отзыв той же сессии — 404.
again := doJSON(t, srv, http.MethodDelete, revokePath,
`{"step_up_password":"correct-horse-battery"}`, adminCookie)
if again.Code != http.StatusNotFound {
t.Fatalf("повторный отзыв = %d, want 404", again.Code)
}
// Аудит и событие безопасности записаны.
auditRec := instanceGet(t, srv, adminCookie, "/api/v1/instance/audit?action=instance.user_session_revoke", http.StatusOK)
audit := decodeResponse[struct {
Total int `json:"total"`
}](t, auditRec)
if audit.Total != 1 {
t.Fatalf("записей аудита об отзыве = %d, want 1", audit.Total)
}
cardRec = instanceGet(t, srv, adminCookie, "/api/v1/instance/users/"+targetID, http.StatusOK)
card = decodeResponse[struct {
Sessions []struct {
ID string `json:"id"`
} `json:"sessions"`
}](t, cardRec)
if len(card.Sessions) != 1 {
t.Fatalf("устройств после отзыва = %d, want 1", len(card.Sessions))
}
eventsRec := instanceGet(t, srv, adminCookie, "/api/v1/instance/users/"+targetID, http.StatusOK)
events := decodeResponse[struct {
SecurityEvents []struct {
Type string `json:"type"`
Metadata struct {
By string `json:"by"`
} `json:"metadata"`
} `json:"security_events"`
}](t, eventsRec)
found := false
for _, event := range events.SecurityEvents {
if event.Type == "session_revoked" && event.Metadata.By == "instance_admin" {
found = true
}
}
if !found {
t.Fatalf("событие session_revoked не записано: %+v", events.SecurityEvents)
}
}
// TestInstanceReset2FA: сброс второго фактора требует step-up, защищает
// администраторов и записывается в аудит и события безопасности (AGENT.md 7.18).
func TestInstanceReset2FA(t *testing.T) {
srv, _ := newTestServer(t)
adminCookie := registerAndLogin(t, srv, "tfa_admin", "tfa-admin@example.com")
promoteAdmin(t, srv, "tfa-admin@example.com")
registerAndLogin(t, srv, "tfa_target", "tfa-target@example.com")
target, err := srv.auth.UserByEmail(t.Context(), "tfa-target@example.com")
if err != nil {
t.Fatalf("UserByEmail: %v", err)
}
targetID := formatSnowflake(target.ID)
resetPath := "/api/v1/instance/users/" + targetID + "/reset-2fa"
// Без step-up сброс отклоняется: проверка свежести аутентификации идёт до
// проверок цели (AGENT.md 7.1).
noStepUp := doJSON(t, srv, http.MethodPost, resetPath, `{}`, adminCookie)
if noStepUp.Code != http.StatusForbidden {
t.Fatalf("сброс без step-up = %d, want 403", noStepUp.Code)
}
if code := errorCodeOf(t, noStepUp); code != "auth.step_up_required" {
t.Fatalf("код отказа без step-up = %q", code)
}
// С подтверждением, но без включённого второго фактора сбрасывать нечего.
empty := doJSON(t, srv, http.MethodPost, resetPath,
`{"step_up_password":"correct-horse-battery"}`, adminCookie)
if empty.Code != http.StatusUnprocessableEntity {
t.Fatalf("сброс без 2FA = %d, want 422", empty.Code)
}
// Включаем второй фактор напрямую в хранилище (как это делает раздел
// безопасности) и сбрасываем его администратором.
if err := srv.store.UpsertTOTPSecret(t.Context(), target.ID, "encrypted-secret"); err != nil {
t.Fatalf("записать секрет: %v", err)
}
if err := srv.store.EnableTOTP(t.Context(), target.ID, nil); err != nil {
t.Fatalf("включить 2FA: %v", err)
}
ok := doJSON(t, srv, http.MethodPost, resetPath,
`{"step_up_password":"correct-horse-battery"}`, adminCookie)
if ok.Code != http.StatusOK {
t.Fatalf("сброс 2FA = %d (%s)", ok.Code, ok.Body.String())
}
secret, err := srv.store.GetTOTPSecret(t.Context(), target.ID)
if err == nil && secret.Enabled {
t.Fatal("второй фактор остался включённым после сброса")
}
// Аудит и событие безопасности.
auditRec := instanceGet(t, srv, adminCookie, "/api/v1/instance/audit?action=instance.user_2fa_reset", http.StatusOK)
audit := decodeResponse[struct {
Total int `json:"total"`
}](t, auditRec)
if audit.Total != 1 {
t.Fatalf("записей аудита о сбросе 2FA = %d, want 1", audit.Total)
}
cardRec := instanceGet(t, srv, adminCookie, "/api/v1/instance/users/"+targetID, http.StatusOK)
card := decodeResponse[struct {
TOTPEnabled bool `json:"totp_enabled"`
SecurityEvents []struct {
Type string `json:"type"`
} `json:"security_events"`
}](t, cardRec)
if card.TOTPEnabled {
t.Fatal("карточка всё ещё показывает включённый второй фактор")
}
foundEvent := false
for _, event := range card.SecurityEvents {
if event.Type == "2fa_change" {
foundEvent = true
}
}
if !foundEvent {
t.Fatalf("событие 2fa_change не записано: %+v", card.SecurityEvents)
}
// Себя сбрасывать нельзя: свой второй фактор меняют в настройках.
self := doJSON(t, srv, http.MethodPost,
"/api/v1/instance/users/"+adminIDOf(t, srv, "tfa-admin@example.com")+"/reset-2fa",
`{"step_up_password":"correct-horse-battery"}`, adminCookie)
if self.Code != http.StatusUnprocessableEntity {
t.Fatalf("сброс себе = %d, want 422", self.Code)
}
// Другого администратора — тоже: иначе сброс чужого ключа обходил бы
// обязательный второй фактор инстанс-админа (AGENT.md 7.19).
registerAndLogin(t, srv, "tfa_admin2", "tfa-admin2@example.com")
promoteAdmin(t, srv, "tfa-admin2@example.com")
protected := doJSON(t, srv, http.MethodPost,
"/api/v1/instance/users/"+adminIDOf(t, srv, "tfa-admin2@example.com")+"/reset-2fa",
`{"step_up_password":"correct-horse-battery"}`, adminCookie)
if protected.Code != http.StatusForbidden {
t.Fatalf("сброс 2FA другого администратора = %d, want 403", protected.Code)
}
}
// adminIDOf возвращает snowflake-идентификатор пользователя по email.
func adminIDOf(t *testing.T, srv *Server, email string) string {
t.Helper()
user, err := srv.auth.UserByEmail(t.Context(), email)
if err != nil {
t.Fatalf("UserByEmail(%s): %v", email, err)
}
return formatSnowflake(user.ID)
}
// TestInstanceGuildListFilters: список серверов панели ищет по названию без
// учёта регистра, фильтрует по владельцу и листается (AGENT.md 7.18).
func TestInstanceGuildListFilters(t *testing.T) {
srv, _ := newTestServer(t)
adminCookie := registerAndLogin(t, srv, "list_admin", "list-admin@example.com")
promoteAdmin(t, srv, "list-admin@example.com")
ownerCookie := registerAndLogin(t, srv, "list_owner", "list-owner@example.com")
ownerID := adminIDOf(t, srv, "list-owner@example.com")
for _, name := range []string{"Альфа", "Бета"} {
rec := doJSON(t, srv, http.MethodPost, "/api/v1/guilds", `{"name":"`+name+`"}`, ownerCookie)
if rec.Code != http.StatusOK {
t.Fatalf("создать сервер %s = %d (%s)", name, rec.Code, rec.Body.String())
}
}
type guildList struct {
Guilds []struct {
ID string `json:"id"`
Name string `json:"name"`
OwnerID string `json:"owner_id"`
OwnerName string `json:"owner_name"`
MemberCount int `json:"member_count"`
} `json:"guilds"`
Total int `json:"total"`
}
allRec := instanceGet(t, srv, adminCookie, "/api/v1/instance/guilds?limit=200", http.StatusOK)
list := decodeResponse[guildList](t, allRec)
if list.Total < 2 || len(list.Guilds) < 2 {
t.Fatalf("список серверов: %+v (total %d)", list.Guilds, list.Total)
}
found := false
for _, guild := range list.Guilds {
if guild.Name == "Альфа" {
found = true
if guild.OwnerName != "list_owner" || guild.MemberCount != 1 {
t.Fatalf("строка сервера: %+v", guild)
}
}
}
if !found {
t.Fatal("сервер «Альфа» не найден")
}
// Поиск без учёта регистра: кириллица в SQLite lower() не приводится,
// поэтому сравнивается нормализованная копия названия.
searchRec := instanceGet(t, srv, adminCookie, "/api/v1/instance/guilds?q=альф", http.StatusOK)
search := decodeResponse[guildList](t, searchRec)
if search.Total != 1 || len(search.Guilds) != 1 || search.Guilds[0].Name != "Альфа" {
t.Fatalf("поиск по названию: %+v (total %d)", search.Guilds, search.Total)
}
ownerRec := instanceGet(t, srv, adminCookie, "/api/v1/instance/guilds?owner_id="+ownerID, http.StatusOK)
byOwner := decodeResponse[guildList](t, ownerRec)
if byOwner.Total != 2 {
t.Fatalf("фильтр по владельцу: total %d, want 2", byOwner.Total)
}
pageRec := instanceGet(t, srv, adminCookie, "/api/v1/instance/guilds?limit=1&offset=1", http.StatusOK)
page := decodeResponse[guildList](t, pageRec)
if len(page.Guilds) != 1 || page.Total != byOwner.Total {
t.Fatalf("пагинация серверов: %+v (total %d)", page.Guilds, page.Total)
}
// Переименование обновляет нормализованное название: старый поиск пуст.
if _, adminErr := srv.auth.UserByEmail(t.Context(), "list-admin@example.com"); adminErr != nil {
t.Fatalf("UserByEmail: %v", adminErr)
}
renameRec := doJSON(t, srv, http.MethodPatch, "/api/v1/instance/guilds/"+search.Guilds[0].ID,
`{"name":"Гамма"}`, adminCookie)
if renameRec.Code != http.StatusOK {
t.Fatalf("переименование = %d (%s)", renameRec.Code, renameRec.Body.String())
}
oldRec := instanceGet(t, srv, adminCookie, "/api/v1/instance/guilds?q=альф", http.StatusOK)
old := decodeResponse[guildList](t, oldRec)
if old.Total != 0 {
t.Fatalf("после переименования старый поиск вернул %d записей", old.Total)
}
newRec := instanceGet(t, srv, adminCookie, "/api/v1/instance/guilds?q=гамма", http.StatusOK)
renamed := decodeResponse[guildList](t, newRec)
if renamed.Total != 1 {
t.Fatalf("поиск по новому названию: total %d, want 1", renamed.Total)
}
}
+9 -3
View File
@@ -97,6 +97,9 @@ type Server struct {
hostCPUSampler *sysinfo.Sampler
// metricsLimiter — отдельный лимит для ручки метрик (опрос раз в секунду).
metricsLimiter *httpx.RateLimiter
// auditExportLimiter — выгрузка журнала инстанса: тяжёлый ответ, поэтому
// 5 экспортов в минуту на администратора (AGENT.md 7.18, 8.6).
auditExportLimiter *httpx.RateLimiter
// health — кэш тяжёлых проверок инстанса.
health healthCache
// presence — время последнего обновления last_seen по пользователю.
@@ -153,9 +156,11 @@ func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Se
webhookSeen: map[string]time.Time{},
// Дашборд опрашивает метрики раз в секунду: 120/мин с запасом.
metricsLimiter: httpx.NewRateLimiter(120, 30),
sysinfoPaths: sysinfo.DefaultPaths(),
voice: voice.NewIssuer(cfg.LiveKitAPIKey, cfg.LiveKitAPISecret, cfg.LiveKitTokenTTL),
voiceAdmin: voice.NewAdminClient(voice.NewIssuer(cfg.LiveKitAPIKey, cfg.LiveKitAPISecret, cfg.LiveKitTokenTTL), cfg.LiveKitAPIURL),
// Экспорт журнала — редкое и тяжёлое действие: 5 раз в минуту.
auditExportLimiter: httpx.NewRateLimiterWindow(5, time.Minute, 5),
sysinfoPaths: sysinfo.DefaultPaths(),
voice: voice.NewIssuer(cfg.LiveKitAPIKey, cfg.LiveKitAPISecret, cfg.LiveKitTokenTTL),
voiceAdmin: voice.NewAdminClient(voice.NewIssuer(cfg.LiveKitAPIKey, cfg.LiveKitAPISecret, cfg.LiveKitTokenTTL), cfg.LiveKitAPIURL),
}
s.cpuSampler = sysinfo.NewSampler(s.sysinfoPaths)
s.hostCPUSampler = sysinfo.NewHostSampler(s.sysinfoPaths)
@@ -200,6 +205,7 @@ func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Se
s.registerUserRoutes(s.api)
s.registerGuildRoutes(s.api)
s.registerInstanceRoutes(s.api)
s.registerInstanceExportRoutes(apiRouter)
s.registerBanRoutes(s.api)
s.registerMetricsRoutes(s.api)
s.registerMessageRoutes(s.api)