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,193 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"math"
|
||||
"time"
|
||||
)
|
||||
|
||||
// InstanceSettings — настраиваемые флаги и лимиты инстанса (AGENT.md 6.5).
|
||||
type InstanceSettings struct {
|
||||
RegistrationEnabled bool
|
||||
AntiBotEnabled bool
|
||||
AllowGuildCreation bool
|
||||
MainGuildID uint64
|
||||
MaxGuildsPerUser int
|
||||
MaxMembersPerGuild int
|
||||
MaxMessageLength int
|
||||
AuditRetentionDays int
|
||||
raw map[string]string
|
||||
}
|
||||
|
||||
const instanceSettingsQuery = `SELECT key, value FROM instance_settings`
|
||||
|
||||
func (s *Store) InstanceSettings(ctx context.Context) (*InstanceSettings, error) {
|
||||
rows, err := s.reader.QueryContext(ctx, instanceSettingsQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
values := map[string]string{}
|
||||
for rows.Next() {
|
||||
var key, value string
|
||||
if err := rows.Scan(&key, &value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
values[key] = value
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return settingsFromMap(values), nil
|
||||
}
|
||||
|
||||
func settingsFromMap(values map[string]string) *InstanceSettings {
|
||||
settings := &InstanceSettings{
|
||||
RegistrationEnabled: values["registration_enabled"] == "true",
|
||||
AntiBotEnabled: values["anti_bot_enabled"] == "true",
|
||||
AllowGuildCreation: values["allow_guild_creation"] != "false",
|
||||
MaxGuildsPerUser: parseSettingInt(values["max_guilds_per_user"], 10),
|
||||
MaxMembersPerGuild: parseSettingInt(values["max_members_per_guild"], 250),
|
||||
MaxMessageLength: parseSettingInt(values["max_message_length"], 4000),
|
||||
AuditRetentionDays: parseSettingInt(values["audit_retention_days"], 90),
|
||||
raw: values,
|
||||
}
|
||||
if mainGuild := values["main_guild_id"]; mainGuild != "" {
|
||||
if parsed, ok := parseUint(mainGuild); ok {
|
||||
settings.MainGuildID = parsed
|
||||
}
|
||||
}
|
||||
return settings
|
||||
}
|
||||
|
||||
// Value возвращает исходное значение настройки (для API и CLI).
|
||||
func (s *InstanceSettings) Value(key string) string { return s.raw[key] }
|
||||
|
||||
func (s *Store) SetInstanceSetting(ctx context.Context, key, value string) error {
|
||||
_, err := s.writer.ExecContext(ctx, `
|
||||
INSERT INTO instance_settings (key, value, updated_at) VALUES (?, ?, ?)
|
||||
ON CONFLICT (key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`,
|
||||
key, value, s.Now())
|
||||
return err
|
||||
}
|
||||
|
||||
// AuditEntry — запись аудита сервера (AGENT.md 6.1, 7.10).
|
||||
type AuditEntry struct {
|
||||
ID uint64
|
||||
GuildID *uint64
|
||||
ActorID *uint64
|
||||
ActorInstanceAdmin bool
|
||||
Action string
|
||||
TargetType string
|
||||
TargetID *uint64
|
||||
Reason string
|
||||
Changes json.RawMessage
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type AuditParams struct {
|
||||
GuildID *uint64
|
||||
ActorID *uint64
|
||||
ActorInstanceAdmin bool
|
||||
Action string
|
||||
TargetType string
|
||||
TargetID *uint64
|
||||
Reason string
|
||||
Changes any
|
||||
}
|
||||
|
||||
// RecordAudit пишет действие в аудит; ошибка записи не должна ломать основную
|
||||
// операцию, поэтому вызывающий код логирует её отдельно (AGENT.md 7.18).
|
||||
func (s *Store) RecordAudit(ctx context.Context, params AuditParams) error {
|
||||
changes := "{}"
|
||||
if params.Changes != nil {
|
||||
if encoded, err := json.Marshal(params.Changes); err == nil {
|
||||
changes = string(encoded)
|
||||
}
|
||||
}
|
||||
_, err := s.writer.ExecContext(ctx, `
|
||||
INSERT INTO audit_log (id, guild_id, actor_id, actor_instance_admin, action, target_type, target_id, reason, changes_json, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
int64(s.NextID()), nullableID(params.GuildID), nullableID(params.ActorID),
|
||||
boolToInt(params.ActorInstanceAdmin), params.Action, params.TargetType,
|
||||
nullableID(params.TargetID), params.Reason, changes, s.Now())
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) ListAuditLog(ctx context.Context, guildID uint64, limit int) ([]AuditEntry, error) {
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 50
|
||||
}
|
||||
rows, err := s.reader.QueryContext(ctx, `
|
||||
SELECT id, guild_id, actor_id, actor_instance_admin, action, target_type, target_id, reason, changes_json, created_at
|
||||
FROM audit_log WHERE guild_id = ? ORDER BY id DESC LIMIT ?`, int64(guildID), limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
entries := make([]AuditEntry, 0, limit)
|
||||
for rows.Next() {
|
||||
var (
|
||||
entry AuditEntry
|
||||
guildID sql.NullInt64
|
||||
actorID sql.NullInt64
|
||||
targetID sql.NullInt64
|
||||
isAdmin int
|
||||
changes string
|
||||
createdAt string
|
||||
)
|
||||
if err := rows.Scan(&entry.ID, &guildID, &actorID, &isAdmin, &entry.Action,
|
||||
&entry.TargetType, &targetID, &entry.Reason, &changes, &createdAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entry.GuildID = optionalID(guildID)
|
||||
entry.ActorID = optionalID(actorID)
|
||||
entry.TargetID = optionalID(targetID)
|
||||
entry.ActorInstanceAdmin = isAdmin == 1
|
||||
entry.Changes = json.RawMessage(changes)
|
||||
entry.CreatedAt = parseTimestamp(createdAt)
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
return entries, rows.Err()
|
||||
}
|
||||
|
||||
func nullableID(id *uint64) any {
|
||||
if id == nil {
|
||||
return nil
|
||||
}
|
||||
return int64(*id)
|
||||
}
|
||||
|
||||
func optionalID(value sql.NullInt64) *uint64 {
|
||||
if !value.Valid {
|
||||
return nil
|
||||
}
|
||||
converted := intToID(value.Int64)
|
||||
return &converted
|
||||
}
|
||||
|
||||
func parseSettingInt(value string, fallback int) int {
|
||||
parsed, ok := parseUint(value)
|
||||
if !ok || parsed > math.MaxInt32 {
|
||||
return fallback
|
||||
}
|
||||
return int(parsed)
|
||||
}
|
||||
|
||||
func parseUint(value string) (uint64, bool) {
|
||||
var result uint64
|
||||
if value == "" {
|
||||
return 0, false
|
||||
}
|
||||
for _, r := range value {
|
||||
if r < '0' || r > '9' {
|
||||
return 0, false
|
||||
}
|
||||
result = result*10 + uint64(r-'0')
|
||||
}
|
||||
return result, true
|
||||
}
|
||||
Reference in New Issue
Block a user