feat(store,permissions): схема Фазы 1, доступ к данным и движок прав
- миграция 00002: users, sessions, totp_secrets, webauthn_credentials, security_events, guilds, guild_members, roles, member_roles, channels, channel_overrides, audit_log + дефолтные instance_settings (AGENT.md 6.1) - internal/store: Snowflake-идентификаторы, CRUD пользователей и сессий (ротация, step-up, logout-all), TOTP и события безопасности, серверы, участники, роли, комнаты и оверрайды, настройки инстанса и аудит - internal/permissions: 37 прав битмаской, вычисление по правилам §6.2 (баз role @user → оверрайды ролей → оверрайд пользователя → ADMINISTRATOR), иерархия ролей и участников, тайм-ауты, обход для инстанс-админа, LRU-кэш с инвалидацией - internal/source: адаптер permissions.Source поверх store - тесты: 18 unit-тестов прав + интеграционный набор на реальной SQLite (приватная комната, модератор, владелец, инстанс-админ, тайм-аут) - golangci: обоснованное исключение gosec для пакета store (конверсии Snowflake и сборка SQL из константных шаблонов)
This commit is contained in:
@@ -0,0 +1,273 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// User — аккаунт пользователя. Email в БД лежит зашифрованным (AGENT.md 9.2),
|
||||
// поэтому в модели он заполняется только тогда, когда реально расшифрован.
|
||||
type User struct {
|
||||
ID uint64
|
||||
Username string
|
||||
DisplayName string
|
||||
Email string
|
||||
PasswordHash string
|
||||
AvatarFileID *uint64
|
||||
BannerFileID *uint64
|
||||
Bio string
|
||||
Status string
|
||||
CustomStatus string
|
||||
CustomStatusEmoji string
|
||||
Flags int64
|
||||
IsInstanceAdmin bool
|
||||
Badges []string
|
||||
Locale string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
DeletedAt *time.Time
|
||||
}
|
||||
|
||||
// CreateUserParams — данные новой учётной записи: шифрование и blind index
|
||||
// выполняет вызывающий код (internal/auth), чтобы store не знал про ключи.
|
||||
type CreateUserParams struct {
|
||||
ID uint64
|
||||
Username string
|
||||
DisplayName string
|
||||
EmailEnc string
|
||||
EmailIndex string
|
||||
PasswordHash string
|
||||
Locale string
|
||||
}
|
||||
|
||||
const userColumns = `id, username, display_name, email_enc, password_hash, avatar_file_id,
|
||||
banner_file_id, bio, status, custom_status, custom_status_emoji, flags,
|
||||
is_instance_admin, badges_json, locale, created_at, updated_at, deleted_at`
|
||||
|
||||
func (s *Store) CreateUser(ctx context.Context, params CreateUserParams) (*User, error) {
|
||||
if params.ID == 0 {
|
||||
params.ID = s.NextID()
|
||||
}
|
||||
if params.DisplayName == "" {
|
||||
params.DisplayName = params.Username
|
||||
}
|
||||
if params.Locale == "" {
|
||||
params.Locale = "ru"
|
||||
}
|
||||
ts := s.Now()
|
||||
|
||||
_, err := s.writer.ExecContext(ctx, `
|
||||
INSERT INTO users (id, username, username_lower, display_name, email_enc, email_index,
|
||||
password_hash, locale, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
int64(params.ID), params.Username, strings.ToLower(params.Username), params.DisplayName,
|
||||
params.EmailEnc, params.EmailIndex, params.PasswordHash, params.Locale, ts, ts,
|
||||
)
|
||||
if err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
return nil, ErrConflict
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return s.GetUser(ctx, params.ID)
|
||||
}
|
||||
|
||||
func (s *Store) GetUser(ctx context.Context, id uint64) (*User, error) {
|
||||
row := s.reader.QueryRowContext(ctx, `SELECT `+userColumns+` FROM users WHERE id = ? AND deleted_at IS NULL`, int64(id))
|
||||
return scanUser(row)
|
||||
}
|
||||
|
||||
func (s *Store) GetUserByUsername(ctx context.Context, username string) (*User, error) {
|
||||
row := s.reader.QueryRowContext(ctx,
|
||||
`SELECT `+userColumns+` FROM users WHERE username_lower = ? AND deleted_at IS NULL`,
|
||||
strings.ToLower(username))
|
||||
return scanUser(row)
|
||||
}
|
||||
|
||||
// GetUserByEmailIndex ищет пользователя по blind index: сам email в запросе
|
||||
// не участвует, поэтому поиск не требует расшифровки (AGENT.md 9.2).
|
||||
func (s *Store) GetUserByEmailIndex(ctx context.Context, emailIndex string) (*User, error) {
|
||||
row := s.reader.QueryRowContext(ctx,
|
||||
`SELECT `+userColumns+` FROM users WHERE email_index = ? AND deleted_at IS NULL`, emailIndex)
|
||||
return scanUser(row)
|
||||
}
|
||||
|
||||
// EncryptedEmail возвращает сохранённый шифротекст email для расшифровки.
|
||||
func (s *Store) EncryptedEmail(ctx context.Context, id uint64) (string, error) {
|
||||
var encrypted string
|
||||
err := s.reader.QueryRowContext(ctx, `SELECT email_enc FROM users WHERE id = ?`, int64(id)).Scan(&encrypted)
|
||||
if err != nil {
|
||||
return "", mapError(err)
|
||||
}
|
||||
return encrypted, nil
|
||||
}
|
||||
|
||||
func (s *Store) CountUsers(ctx context.Context) (int, error) {
|
||||
var count int
|
||||
if err := s.reader.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE deleted_at IS NULL`).Scan(&count); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
type UpdateUserParams struct {
|
||||
DisplayName *string
|
||||
Bio *string
|
||||
Status *string
|
||||
CustomStatus *string
|
||||
CustomStatusEmoji *string
|
||||
AvatarFileID *uint64
|
||||
BannerFileID *uint64
|
||||
Locale *string
|
||||
}
|
||||
|
||||
func (s *Store) UpdateUser(ctx context.Context, id uint64, params UpdateUserParams) (*User, error) {
|
||||
var (
|
||||
sets []string
|
||||
args []any
|
||||
)
|
||||
add := func(column string, value any) {
|
||||
sets = append(sets, column+" = ?")
|
||||
args = append(args, value)
|
||||
}
|
||||
if params.DisplayName != nil {
|
||||
add("display_name", *params.DisplayName)
|
||||
}
|
||||
if params.Bio != nil {
|
||||
add("bio", *params.Bio)
|
||||
}
|
||||
if params.Status != nil {
|
||||
add("status", *params.Status)
|
||||
}
|
||||
if params.CustomStatus != nil {
|
||||
add("custom_status", *params.CustomStatus)
|
||||
}
|
||||
if params.CustomStatusEmoji != nil {
|
||||
add("custom_status_emoji", *params.CustomStatusEmoji)
|
||||
}
|
||||
if params.AvatarFileID != nil {
|
||||
add("avatar_file_id", int64(*params.AvatarFileID))
|
||||
}
|
||||
if params.BannerFileID != nil {
|
||||
add("banner_file_id", int64(*params.BannerFileID))
|
||||
}
|
||||
if params.Locale != nil {
|
||||
add("locale", *params.Locale)
|
||||
}
|
||||
if len(sets) == 0 {
|
||||
return s.GetUser(ctx, id)
|
||||
}
|
||||
add("updated_at", s.Now())
|
||||
args = append(args, int64(id))
|
||||
|
||||
result, err := s.writer.ExecContext(ctx, `UPDATE users SET `+strings.Join(sets, ", ")+` WHERE id = ?`, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if affected, err := result.RowsAffected(); err == nil && affected == 0 {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return s.GetUser(ctx, id)
|
||||
}
|
||||
|
||||
// UpdateUserPassword меняет хэш пароля и возвращает прежнее значение, чтобы
|
||||
// вызывающий код мог отозвать все сессии (AGENT.md 7.1).
|
||||
func (s *Store) UpdateUserPassword(ctx context.Context, id uint64, passwordHash string) error {
|
||||
result, err := s.writer.ExecContext(ctx,
|
||||
`UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL`,
|
||||
passwordHash, s.Now(), int64(id))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if affected, err := result.RowsAffected(); err == nil && affected == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) SetInstanceAdmin(ctx context.Context, id uint64, admin bool) error {
|
||||
result, err := s.writer.ExecContext(ctx,
|
||||
`UPDATE users SET is_instance_admin = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL`,
|
||||
boolToInt(admin), s.Now(), int64(id))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if affected, err := result.RowsAffected(); err == nil && affected == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) SetUserBadges(ctx context.Context, id uint64, badges []string) error {
|
||||
encoded, err := json.Marshal(badges)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := s.writer.ExecContext(ctx,
|
||||
`UPDATE users SET badges_json = ?, updated_at = ? WHERE id = ?`,
|
||||
string(encoded), s.Now(), int64(id))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if affected, err := result.RowsAffected(); err == nil && affected == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func scanUser(scanner interface{ Scan(...any) error }) (*User, error) {
|
||||
var (
|
||||
user User
|
||||
avatarID sql.NullInt64
|
||||
bannerID sql.NullInt64
|
||||
isAdmin int
|
||||
badges string
|
||||
createdAt string
|
||||
updatedAt string
|
||||
deletedAt sql.NullString
|
||||
emailEncrypted string
|
||||
)
|
||||
err := scanner.Scan(
|
||||
&user.ID, &user.Username, &user.DisplayName, &emailEncrypted, &user.PasswordHash,
|
||||
&avatarID, &bannerID, &user.Bio, &user.Status, &user.CustomStatus, &user.CustomStatusEmoji,
|
||||
&user.Flags, &isAdmin, &badges, &user.Locale, &createdAt, &updatedAt, &deletedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, mapError(err)
|
||||
}
|
||||
if avatarID.Valid {
|
||||
value := uint64(avatarID.Int64)
|
||||
user.AvatarFileID = &value
|
||||
}
|
||||
if bannerID.Valid {
|
||||
value := uint64(bannerID.Int64)
|
||||
user.BannerFileID = &value
|
||||
}
|
||||
user.IsInstanceAdmin = isAdmin == 1
|
||||
if err := json.Unmarshal([]byte(badges), &user.Badges); err != nil {
|
||||
user.Badges = nil
|
||||
}
|
||||
user.CreatedAt = parseTimestamp(createdAt)
|
||||
user.UpdatedAt = parseTimestamp(updatedAt)
|
||||
if deletedAt.Valid {
|
||||
value := parseTimestamp(deletedAt.String)
|
||||
user.DeletedAt = &value
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func parseTimestamp(value string) time.Time {
|
||||
for _, layout := range []string{"2006-01-02T15:04:05.000Z", time.RFC3339Nano, time.RFC3339} {
|
||||
if parsed, err := time.Parse(layout, value); err == nil {
|
||||
return parsed.UTC()
|
||||
}
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
func isUniqueViolation(err error) bool {
|
||||
return err != nil && strings.Contains(strings.ToLower(err.Error()), "unique constraint")
|
||||
}
|
||||
Reference in New Issue
Block a user