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,294 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Role struct {
|
||||
ID uint64
|
||||
GuildID uint64
|
||||
Name string
|
||||
Color int64
|
||||
Position int
|
||||
Permissions uint64
|
||||
IsDefault bool
|
||||
Mentionable bool
|
||||
Hoist bool
|
||||
CosmeticFrameID *uint64
|
||||
CosmeticBadgeID *uint64
|
||||
NickColor *int64
|
||||
NickEffect string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// CreateRoleParams — параметры роли. Пермишены хранятся как в SQLite (int64),
|
||||
// а в модели — uint64: конвертация в одном месте.
|
||||
type CreateRoleParams struct {
|
||||
ID uint64
|
||||
GuildID uint64
|
||||
Name string
|
||||
Color int64
|
||||
Position int
|
||||
Permissions uint64
|
||||
IsDefault bool
|
||||
Mentionable bool
|
||||
Hoist bool
|
||||
NickEffect string
|
||||
}
|
||||
|
||||
const roleColumns = `id, guild_id, name, color, position, permissions, is_default,
|
||||
mentionable, hoist, cosmetic_frame_id, cosmetic_badge_id, nick_color, nick_effect, created_at`
|
||||
|
||||
func (s *Store) CreateRole(ctx context.Context, params CreateRoleParams) (*Role, error) {
|
||||
if params.ID == 0 {
|
||||
params.ID = s.NextID()
|
||||
}
|
||||
if params.NickEffect == "" {
|
||||
params.NickEffect = "none"
|
||||
}
|
||||
_, err := s.writer.ExecContext(ctx, `
|
||||
INSERT INTO roles (id, guild_id, name, color, position, permissions, is_default, mentionable, hoist, nick_effect, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
int64(params.ID), int64(params.GuildID), params.Name, params.Color, params.Position,
|
||||
int64(params.Permissions), boolToInt(params.IsDefault), boolToInt(params.Mentionable),
|
||||
boolToInt(params.Hoist), params.NickEffect, s.Now())
|
||||
if err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
return nil, ErrConflict
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return s.GetRole(ctx, params.ID)
|
||||
}
|
||||
|
||||
func (s *Store) GetRole(ctx context.Context, id uint64) (*Role, error) {
|
||||
return scanRole(s.reader.QueryRowContext(ctx, `SELECT `+roleColumns+` FROM roles WHERE id = ?`, int64(id)))
|
||||
}
|
||||
|
||||
// ListGuildRoles возвращает роли сервера по убыванию позиции (AGENT.md 7.4).
|
||||
func (s *Store) ListGuildRoles(ctx context.Context, guildID uint64) ([]Role, error) {
|
||||
rows, err := s.reader.QueryContext(ctx,
|
||||
`SELECT `+roleColumns+` FROM roles WHERE guild_id = ? ORDER BY position DESC, id`, int64(guildID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
roles := make([]Role, 0, 4)
|
||||
for rows.Next() {
|
||||
role, err := scanRole(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
roles = append(roles, *role)
|
||||
}
|
||||
return roles, rows.Err()
|
||||
}
|
||||
|
||||
// DefaultRole возвращает роль @user (is_default) — базу для вычисления прав.
|
||||
func (s *Store) DefaultRole(ctx context.Context, guildID uint64) (*Role, error) {
|
||||
return scanRole(s.reader.QueryRowContext(ctx,
|
||||
`SELECT `+roleColumns+` FROM roles WHERE guild_id = ? AND is_default = 1 ORDER BY position LIMIT 1`,
|
||||
int64(guildID)))
|
||||
}
|
||||
|
||||
type UpdateRoleParams struct {
|
||||
Name *string
|
||||
Color *int64
|
||||
Position *int
|
||||
Permissions *uint64
|
||||
Mentionable *bool
|
||||
Hoist *bool
|
||||
CosmeticFrameID *uint64
|
||||
CosmeticBadgeID *uint64
|
||||
NickColor *int64
|
||||
NickEffect *string
|
||||
ClearFrame bool
|
||||
ClearBadge bool
|
||||
ClearNickColor bool
|
||||
}
|
||||
|
||||
func (s *Store) UpdateRole(ctx context.Context, id uint64, params UpdateRoleParams) (*Role, error) {
|
||||
sets := []string{}
|
||||
args := []any{}
|
||||
if params.Name != nil {
|
||||
sets = append(sets, "name = ?")
|
||||
args = append(args, *params.Name)
|
||||
}
|
||||
if params.Color != nil {
|
||||
sets = append(sets, "color = ?")
|
||||
args = append(args, *params.Color)
|
||||
}
|
||||
if params.Position != nil {
|
||||
sets = append(sets, "position = ?")
|
||||
args = append(args, *params.Position)
|
||||
}
|
||||
if params.Permissions != nil {
|
||||
sets = append(sets, "permissions = ?")
|
||||
args = append(args, int64(*params.Permissions))
|
||||
}
|
||||
if params.Mentionable != nil {
|
||||
sets = append(sets, "mentionable = ?")
|
||||
args = append(args, boolToInt(*params.Mentionable))
|
||||
}
|
||||
if params.Hoist != nil {
|
||||
sets = append(sets, "hoist = ?")
|
||||
args = append(args, boolToInt(*params.Hoist))
|
||||
}
|
||||
if params.CosmeticFrameID != nil {
|
||||
sets = append(sets, "cosmetic_frame_id = ?")
|
||||
args = append(args, int64(*params.CosmeticFrameID))
|
||||
}
|
||||
if params.ClearFrame {
|
||||
sets = append(sets, "cosmetic_frame_id = NULL")
|
||||
}
|
||||
if params.CosmeticBadgeID != nil {
|
||||
sets = append(sets, "cosmetic_badge_id = ?")
|
||||
args = append(args, int64(*params.CosmeticBadgeID))
|
||||
}
|
||||
if params.ClearBadge {
|
||||
sets = append(sets, "cosmetic_badge_id = NULL")
|
||||
}
|
||||
if params.NickColor != nil {
|
||||
sets = append(sets, "nick_color = ?")
|
||||
args = append(args, *params.NickColor)
|
||||
}
|
||||
if params.ClearNickColor {
|
||||
sets = append(sets, "nick_color = NULL")
|
||||
}
|
||||
if params.NickEffect != nil {
|
||||
sets = append(sets, "nick_effect = ?")
|
||||
args = append(args, *params.NickEffect)
|
||||
}
|
||||
if len(sets) == 0 {
|
||||
return s.GetRole(ctx, id)
|
||||
}
|
||||
args = append(args, int64(id))
|
||||
if _, err := s.writer.ExecContext(ctx, buildQuery(updateRoleTemplate, strings.Join(sets, ", ")), args...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.GetRole(ctx, id)
|
||||
}
|
||||
|
||||
func (s *Store) DeleteRole(ctx context.Context, id uint64) error {
|
||||
_, err := s.writer.ExecContext(ctx, `DELETE FROM roles WHERE id = ?`, int64(id))
|
||||
return err
|
||||
}
|
||||
|
||||
// AssignRole выдаёт роль участнику (идемпотентно).
|
||||
func (s *Store) AssignRole(ctx context.Context, guildID, userID, roleID uint64) error {
|
||||
_, err := s.writer.ExecContext(ctx, `
|
||||
INSERT INTO member_roles (guild_id, user_id, role_id) VALUES (?, ?, ?)
|
||||
ON CONFLICT DO NOTHING`, int64(guildID), int64(userID), int64(roleID))
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) RemoveRole(ctx context.Context, guildID, userID, roleID uint64) error {
|
||||
_, err := s.writer.ExecContext(ctx,
|
||||
`DELETE FROM member_roles WHERE guild_id = ? AND user_id = ? AND role_id = ?`,
|
||||
int64(guildID), int64(userID), int64(roleID))
|
||||
return err
|
||||
}
|
||||
|
||||
// MemberRoles возвращает роли участника сервера.
|
||||
func (s *Store) MemberRoles(ctx context.Context, guildID, userID uint64) ([]Role, error) {
|
||||
rows, err := s.reader.QueryContext(ctx, buildQuery(selectMemberRolesTemplate, prefixColumns("r", roleColumns)),
|
||||
int64(guildID), int64(userID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
roles := make([]Role, 0, 4)
|
||||
for rows.Next() {
|
||||
role, err := scanRole(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
roles = append(roles, *role)
|
||||
}
|
||||
return roles, rows.Err()
|
||||
}
|
||||
|
||||
// MemberRoleIDs возвращает идентификаторы ролей участника (для READY).
|
||||
func (s *Store) MemberRoleIDs(ctx context.Context, guildID, userID uint64) ([]uint64, error) {
|
||||
rows, err := s.reader.QueryContext(ctx,
|
||||
`SELECT role_id FROM member_roles WHERE guild_id = ? AND user_id = ?`, int64(guildID), int64(userID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
ids := make([]uint64, 0, 4)
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids = append(ids, uint64(id))
|
||||
}
|
||||
return ids, rows.Err()
|
||||
}
|
||||
|
||||
// RoleMemberCount считает участников с ролью (нужно для защиты от потери
|
||||
// управления сервером, AGENT.md 6.3).
|
||||
func (s *Store) RoleMemberCount(ctx context.Context, roleID uint64) (int, error) {
|
||||
var count int
|
||||
err := s.reader.QueryRowContext(ctx, `SELECT COUNT(*) FROM member_roles WHERE role_id = ?`, int64(roleID)).Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
// GuildRolesWithManageRoles возвращает роли, дающие право управлять ролями.
|
||||
func (s *Store) GuildRolesWithManageRoles(ctx context.Context, guildID uint64, permission uint64) ([]Role, error) {
|
||||
rows, err := s.reader.QueryContext(ctx, `
|
||||
SELECT `+roleColumns+` FROM roles
|
||||
WHERE guild_id = ? AND (permissions & ?) <> 0`, int64(guildID), int64(permission))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
roles := make([]Role, 0, 2)
|
||||
for rows.Next() {
|
||||
role, err := scanRole(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
roles = append(roles, *role)
|
||||
}
|
||||
return roles, rows.Err()
|
||||
}
|
||||
|
||||
func scanRole(scanner interface{ Scan(...any) error }) (*Role, error) {
|
||||
var (
|
||||
role Role
|
||||
permissions int64
|
||||
isDefault int
|
||||
mentionable int
|
||||
hoist int
|
||||
frameID sql.NullInt64
|
||||
badgeID sql.NullInt64
|
||||
nickColor sql.NullInt64
|
||||
createdAt string
|
||||
)
|
||||
err := scanner.Scan(&role.ID, &role.GuildID, &role.Name, &role.Color, &role.Position, &permissions,
|
||||
&isDefault, &mentionable, &hoist, &frameID, &badgeID, &nickColor, &role.NickEffect, &createdAt)
|
||||
if err != nil {
|
||||
return nil, mapError(err)
|
||||
}
|
||||
role.Permissions = uint64(permissions)
|
||||
role.IsDefault = isDefault == 1
|
||||
role.Mentionable = mentionable == 1
|
||||
role.Hoist = hoist == 1
|
||||
role.CosmeticFrameID = optionalID(frameID)
|
||||
role.CosmeticBadgeID = optionalID(badgeID)
|
||||
if nickColor.Valid {
|
||||
value := nickColor.Int64
|
||||
role.NickColor = &value
|
||||
}
|
||||
role.CreatedAt = parseTimestamp(createdAt)
|
||||
return &role, nil
|
||||
}
|
||||
Reference in New Issue
Block a user