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,112 @@
|
||||
// Package store содержит доступ к данным glchat: модели и запросы поверх SQLite.
|
||||
// Вся работа с БД идёт только через этот пакет (AGENT.md §12).
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"glchat/internal/database"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("record not found")
|
||||
ErrConflict = errors.New("record already exists")
|
||||
ErrPermissionDenied = errors.New("permission denied")
|
||||
ErrLastAdminRole = errors.New("guild must keep a role with MANAGE_ROLES")
|
||||
ErrLastAdministrator = errors.New("guild must keep administrator permissions")
|
||||
)
|
||||
|
||||
// Store — точка доступа к данным. Writer используется для мутаций,
|
||||
// Reader — для чтения (один писатель и пул чтения, AGENT.md §9.1).
|
||||
type Store struct {
|
||||
writer *sql.DB
|
||||
reader *sql.DB
|
||||
ids *Snowflake
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func New(db *database.DB) *Store {
|
||||
return &Store{
|
||||
writer: db.Writer,
|
||||
reader: db.Reader,
|
||||
ids: NewSnowflake(0),
|
||||
now: func() time.Time { return time.Now().UTC() },
|
||||
}
|
||||
}
|
||||
|
||||
// Reader возвращает соединение для чтения (доступно другим пакетам,
|
||||
// которым нужны собственные запросы, например permissions).
|
||||
func (s *Store) Reader() *sql.DB { return s.reader }
|
||||
|
||||
// Writer возвращает соединение для записи.
|
||||
func (s *Store) Writer() *sql.DB { return s.writer }
|
||||
|
||||
// NextID выдаёт новый идентификатор (Snowflake, AGENT.md 6.4).
|
||||
func (s *Store) NextID() uint64 { return s.ids.Next() }
|
||||
|
||||
// Timestamp форматирует время в ISO 8601 UTC с миллисекундами.
|
||||
func (s *Store) Timestamp(t time.Time) string {
|
||||
return t.UTC().Format("2006-01-02T15:04:05.000Z")
|
||||
}
|
||||
|
||||
func (s *Store) Now() string { return s.Timestamp(s.now()) }
|
||||
|
||||
// InTx выполняет функцию в транзакции с откатом при ошибке.
|
||||
func (s *Store) InTx(ctx context.Context, fn func(tx *sql.Tx) error) error {
|
||||
tx, err := s.writer.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin transaction: %w", err)
|
||||
}
|
||||
if err := fn(tx); err != nil {
|
||||
if rollbackErr := tx.Rollback(); rollbackErr != nil && !errors.Is(rollbackErr, sql.ErrTxDone) {
|
||||
return fmt.Errorf("%w (rollback: %w)", err, rollbackErr)
|
||||
}
|
||||
return err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit transaction: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func mapError(err error) error {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// idToInt переводит uint64-идентификатор в int64 для SQLite. Идентификаторы
|
||||
// Snowflake формируются приложением, поэтому заведомо меньше MaxInt64;
|
||||
// проверка диапазона сохраняет корректность при ручных данных.
|
||||
func idToInt(id uint64) (int64, error) {
|
||||
if id > math.MaxInt64 {
|
||||
return 0, fmt.Errorf("identifier %d exceeds the supported range", id)
|
||||
}
|
||||
return int64(id), nil
|
||||
}
|
||||
|
||||
// intToID переводит значение SQLite в uint64-идентификатор.
|
||||
func intToID(value int64) uint64 {
|
||||
if value < 0 {
|
||||
return 0
|
||||
}
|
||||
return uint64(value)
|
||||
}
|
||||
|
||||
// boolToInt превращает Go-флаг в значение SQLite (0/1).
|
||||
func boolToInt(value bool) int {
|
||||
if value {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// mustID конвертирует идентификатор, который уже проверен вызывающим кодом
|
||||
// (значения Snowflake всегда в диапазоне int64).
|
||||
func mustID(id uint64) int64 { return int64(id) }
|
||||
Reference in New Issue
Block a user