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,155 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TOTPSecret — секрет второго фактора: в БД лежит зашифрованным (AGENT.md 9.2),
|
||||
// резервные коды хранятся только хэшами.
|
||||
type TOTPSecret struct {
|
||||
UserID uint64
|
||||
SecretEncrypted string
|
||||
Enabled bool
|
||||
RecoveryCodeHashs []string
|
||||
ConfirmedAt *time.Time
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// UpsertTOTPSecret создаёт или заменяет неподтверждённый секрет.
|
||||
func (s *Store) UpsertTOTPSecret(ctx context.Context, userID uint64, secretEncrypted string) error {
|
||||
_, err := s.writer.ExecContext(ctx, `
|
||||
INSERT INTO totp_secrets (user_id, secret_encrypted, enabled, recovery_codes_json, created_at)
|
||||
VALUES (?, ?, 0, '[]', ?)
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
secret_encrypted = excluded.secret_encrypted,
|
||||
enabled = 0,
|
||||
recovery_codes_json = '[]',
|
||||
confirmed_at = NULL,
|
||||
created_at = excluded.created_at`,
|
||||
int64(userID), secretEncrypted, s.Now())
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) GetTOTPSecret(ctx context.Context, userID uint64) (*TOTPSecret, error) {
|
||||
var (
|
||||
secret TOTPSecret
|
||||
enabled int
|
||||
codes string
|
||||
confirmedAt sql.NullString
|
||||
createdAt string
|
||||
)
|
||||
err := s.reader.QueryRowContext(ctx, `
|
||||
SELECT user_id, secret_encrypted, enabled, recovery_codes_json, confirmed_at, created_at
|
||||
FROM totp_secrets WHERE user_id = ?`, int64(userID)).
|
||||
Scan(&secret.UserID, &secret.SecretEncrypted, &enabled, &codes, &confirmedAt, &createdAt)
|
||||
if err != nil {
|
||||
return nil, mapError(err)
|
||||
}
|
||||
secret.Enabled = enabled == 1
|
||||
if err := json.Unmarshal([]byte(codes), &secret.RecoveryCodeHashs); err != nil {
|
||||
secret.RecoveryCodeHashs = nil
|
||||
}
|
||||
if confirmedAt.Valid {
|
||||
value := parseTimestamp(confirmedAt.String)
|
||||
secret.ConfirmedAt = &value
|
||||
}
|
||||
secret.CreatedAt = parseTimestamp(createdAt)
|
||||
return &secret, nil
|
||||
}
|
||||
|
||||
// EnableTOTP подтверждает секрет и сохраняет хэши резервных кодов.
|
||||
func (s *Store) EnableTOTP(ctx context.Context, userID uint64, recoveryCodeHashes []string) error {
|
||||
encoded, err := json.Marshal(recoveryCodeHashes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := s.writer.ExecContext(ctx, `
|
||||
UPDATE totp_secrets SET enabled = 1, recovery_codes_json = ?, confirmed_at = ?
|
||||
WHERE user_id = ?`, string(encoded), s.Now(), int64(userID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if affected, err := result.RowsAffected(); err == nil && affected == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ConsumeRecoveryCode удаляет использованный резервный код.
|
||||
func (s *Store) ConsumeRecoveryCode(ctx context.Context, userID uint64, remaining []string) error {
|
||||
encoded, err := json.Marshal(remaining)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = s.writer.ExecContext(ctx, `UPDATE totp_secrets SET recovery_codes_json = ? WHERE user_id = ?`,
|
||||
string(encoded), int64(userID))
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) DeleteTOTPSecret(ctx context.Context, userID uint64) error {
|
||||
_, err := s.writer.ExecContext(ctx, `DELETE FROM totp_secrets WHERE user_id = ?`, int64(userID))
|
||||
return err
|
||||
}
|
||||
|
||||
// RecordSecurityEvent пишет событие безопасности (AGENT.md 6.1).
|
||||
func (s *Store) RecordSecurityEvent(ctx context.Context, userID *uint64, eventType, ip, userAgent, metadata string) error {
|
||||
var userValue any
|
||||
if userID != nil {
|
||||
userValue = int64(*userID)
|
||||
}
|
||||
if metadata == "" {
|
||||
metadata = "{}"
|
||||
}
|
||||
_, err := s.writer.ExecContext(ctx, `
|
||||
INSERT INTO security_events (id, user_id, type, ip, user_agent, metadata_json, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
int64(s.NextID()), userValue, eventType, ip, userAgent, metadata, s.Now())
|
||||
return err
|
||||
}
|
||||
|
||||
// ListSecurityEvents возвращает последние события пользователя.
|
||||
func (s *Store) ListSecurityEvents(ctx context.Context, userID uint64, limit int) ([]SecurityEvent, error) {
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 20
|
||||
}
|
||||
rows, err := s.reader.QueryContext(ctx, `
|
||||
SELECT id, user_id, type, ip, user_agent, metadata_json, created_at
|
||||
FROM security_events WHERE user_id = ? ORDER BY id DESC LIMIT ?`, int64(userID), limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
events := make([]SecurityEvent, 0, limit)
|
||||
for rows.Next() {
|
||||
var (
|
||||
event SecurityEvent
|
||||
userValue sql.NullInt64
|
||||
createdAt string
|
||||
metadataRaw string
|
||||
)
|
||||
if err := rows.Scan(&event.ID, &userValue, &event.Type, &event.IP, &event.UserAgent, &metadataRaw, &createdAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if userValue.Valid {
|
||||
event.UserID = uint64(userValue.Int64)
|
||||
}
|
||||
event.Metadata = json.RawMessage(metadataRaw)
|
||||
event.CreatedAt = parseTimestamp(createdAt)
|
||||
events = append(events, event)
|
||||
}
|
||||
return events, rows.Err()
|
||||
}
|
||||
|
||||
type SecurityEvent struct {
|
||||
ID uint64
|
||||
UserID uint64
|
||||
Type string
|
||||
IP string
|
||||
UserAgent string
|
||||
Metadata json.RawMessage
|
||||
CreatedAt time.Time
|
||||
}
|
||||
Reference in New Issue
Block a user