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,353 @@
|
||||
// Package permissions — ядро вычисления прав glchat (AGENT.md 6.2, 7.4).
|
||||
// Любая проверка прав на сервере обязана идти через этот пакет.
|
||||
package permissions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Permission — битовая маска прав (uint64, AGENT.md 6.2).
|
||||
type Permission uint64
|
||||
|
||||
const (
|
||||
ViewGuild Permission = 1 << iota
|
||||
ViewChannel
|
||||
ReadMessageHistory
|
||||
SendMessages
|
||||
AttachFiles
|
||||
EmbedLinks
|
||||
AddReactions
|
||||
UseExternalEmojis
|
||||
MentionEveryone
|
||||
ManageMessages
|
||||
ConnectVoice
|
||||
Speak
|
||||
Video
|
||||
ScreenShare
|
||||
UseSoundboard
|
||||
MuteMembers
|
||||
DeafenMembers
|
||||
MoveMembers
|
||||
ManageChannels
|
||||
ManageChannelStatus
|
||||
ManageChannelDescription
|
||||
ManageChannelBackground
|
||||
ManageChannelPermissions
|
||||
ManageGuild
|
||||
ManageRoles
|
||||
ManageEmojis
|
||||
ManageSounds
|
||||
UseWebhooks
|
||||
ManageWebhooks
|
||||
CreateInvites
|
||||
KickMembers
|
||||
BanMembers
|
||||
TimeoutMembers
|
||||
ViewAuditLog
|
||||
ManageNicknames
|
||||
ChangeNickname
|
||||
Administrator
|
||||
)
|
||||
|
||||
// AllPermissions — маска всех прав.
|
||||
const AllPermissions Permission = (1 << 37) - 1
|
||||
|
||||
var permissionNames = map[Permission]string{
|
||||
ViewGuild: "VIEW_GUILD",
|
||||
ViewChannel: "VIEW_CHANNEL",
|
||||
ReadMessageHistory: "READ_MESSAGE_HISTORY",
|
||||
SendMessages: "SEND_MESSAGES",
|
||||
AttachFiles: "ATTACH_FILES",
|
||||
EmbedLinks: "EMBED_LINKS",
|
||||
AddReactions: "ADD_REACTIONS",
|
||||
UseExternalEmojis: "USE_EXTERNAL_EMOJIS",
|
||||
MentionEveryone: "MENTION_EVERYONE",
|
||||
ManageMessages: "MANAGE_MESSAGES",
|
||||
ConnectVoice: "CONNECT_VOICE",
|
||||
Speak: "SPEAK",
|
||||
Video: "VIDEO",
|
||||
ScreenShare: "SCREEN_SHARE",
|
||||
UseSoundboard: "USE_SOUNDBOARD",
|
||||
MuteMembers: "MUTE_MEMBERS",
|
||||
DeafenMembers: "DEAFEN_MEMBERS",
|
||||
MoveMembers: "MOVE_MEMBERS",
|
||||
ManageChannels: "MANAGE_CHANNELS",
|
||||
ManageChannelStatus: "MANAGE_CHANNEL_STATUS",
|
||||
ManageChannelDescription: "MANAGE_CHANNEL_DESCRIPTION",
|
||||
ManageChannelBackground: "MANAGE_CHANNEL_BACKGROUND",
|
||||
ManageChannelPermissions: "MANAGE_CHANNEL_PERMISSIONS",
|
||||
ManageGuild: "MANAGE_GUILD",
|
||||
ManageRoles: "MANAGE_ROLES",
|
||||
ManageEmojis: "MANAGE_EMOJIS",
|
||||
ManageSounds: "MANAGE_SOUNDS",
|
||||
UseWebhooks: "USE_WEBHOOKS",
|
||||
ManageWebhooks: "MANAGE_WEBHOOKS",
|
||||
CreateInvites: "CREATE_INVITES",
|
||||
KickMembers: "KICK_MEMBERS",
|
||||
BanMembers: "BAN_MEMBERS",
|
||||
TimeoutMembers: "TIMEOUT_MEMBERS",
|
||||
ViewAuditLog: "VIEW_AUDIT_LOG",
|
||||
ManageNicknames: "MANAGE_NICKNAMES",
|
||||
ChangeNickname: "CHANGE_NICKNAME",
|
||||
Administrator: "ADMINISTRATOR",
|
||||
}
|
||||
|
||||
func (p Permission) Has(required Permission) bool {
|
||||
return p&required == required
|
||||
}
|
||||
|
||||
// Any сообщает, что установлен хотя бы один бит из маски.
|
||||
func (p Permission) Any(mask Permission) bool {
|
||||
return p&mask != 0
|
||||
}
|
||||
|
||||
func (p Permission) String() string {
|
||||
if p == 0 {
|
||||
return "NONE"
|
||||
}
|
||||
names := make([]string, 0, 4)
|
||||
for bit := Permission(1); bit != 0 && bit <= Administrator; bit <<= 1 {
|
||||
if p&bit != 0 {
|
||||
names = append(names, permissionNames[bit])
|
||||
}
|
||||
}
|
||||
return strings.Join(names, "|")
|
||||
}
|
||||
|
||||
// AllPermissionsList возвращает все права по порядку битов (для API и UI).
|
||||
func AllPermissionsList() []Permission {
|
||||
list := make([]Permission, 0, 37)
|
||||
for bit := ViewGuild; bit <= Administrator; bit <<= 1 {
|
||||
list = append(list, bit)
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
// Name возвращает строковое имя права.
|
||||
func Name(permission Permission) string {
|
||||
if name, ok := permissionNames[permission]; ok {
|
||||
return name
|
||||
}
|
||||
return fmt.Sprintf("UNKNOWN_%d", uint64(permission))
|
||||
}
|
||||
|
||||
// ByName ищет право по имени (нужно API и импорту шаблонов).
|
||||
func ByName(name string) (Permission, bool) {
|
||||
for permission, candidate := range permissionNames {
|
||||
if candidate == strings.ToUpper(strings.TrimSpace(name)) {
|
||||
return permission, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// DefaultUserPermissions — права роли «Пользователь» при создании сервера
|
||||
// (AGENT.md 6.3).
|
||||
const DefaultUserPermissions = ViewGuild | ViewChannel | ReadMessageHistory |
|
||||
SendMessages | AttachFiles | AddReactions | ConnectVoice | Speak
|
||||
|
||||
// Resolved — результат вычисления прав для контекста сервера и комнаты.
|
||||
type Resolved struct {
|
||||
// Guild — права на уровне сервера (без учёта оверрайдов комнаты).
|
||||
Guild Permission
|
||||
// Channel — права с учётом оверрайдов (только для конкретной комнаты).
|
||||
Channel Permission
|
||||
// IsOwner — пользователь владеет сервером: неявный ADMINISTRATOR (AGENT.md 6.2).
|
||||
IsOwner bool
|
||||
// IsInstanceAdmin — глобальная роль «Администратор сервера» (AGENT.md 7.19).
|
||||
IsInstanceAdmin bool
|
||||
// TimedOut — участник в тайм-ауте: отправка сообщений запрещена (AGENT.md 7.10).
|
||||
TimedOut bool
|
||||
}
|
||||
|
||||
// Has — есть ли право на сервере. ADMINISTRATOR даёт все права (AGENT.md 6.2).
|
||||
func (r Resolved) Has(permission Permission) bool {
|
||||
if r.IsInstanceAdmin {
|
||||
return true
|
||||
}
|
||||
if r.Guild.Has(Administrator) {
|
||||
return true
|
||||
}
|
||||
return r.Guild.Has(permission)
|
||||
}
|
||||
|
||||
// Can — есть ли право в комнате (с учётом тайм-аута и оверрайдов).
|
||||
func (r Resolved) Can(permission Permission) bool {
|
||||
if r.IsInstanceAdmin {
|
||||
return true
|
||||
}
|
||||
if r.Channel.Has(Administrator) {
|
||||
return true
|
||||
}
|
||||
if !r.Channel.Has(permission) {
|
||||
return false
|
||||
}
|
||||
if r.TimedOut && permission.Any(SendMessages|AddReactions|AttachFiles|Speak) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// CanViewChannel — может ли пользователь видеть комнату. Требуется именно
|
||||
// VIEW_CHANNEL: CONNECT_VOICE сам по себе комнату в списке не показывает
|
||||
// (текстовая комната без VIEW_CHANNEL скрыта, AGENT.md 7.5).
|
||||
func (r Resolved) CanViewChannel() bool {
|
||||
if r.IsInstanceAdmin {
|
||||
return true
|
||||
}
|
||||
if r.Channel.Has(Administrator) {
|
||||
return true
|
||||
}
|
||||
return r.Channel.Has(ViewChannel)
|
||||
}
|
||||
|
||||
// Source — данные для расчёта прав: роли сервера и роли участника.
|
||||
type Source interface {
|
||||
GuildRoles(ctx context.Context, guildID uint64) ([]RoleData, error)
|
||||
MemberRoleIDs(ctx context.Context, guildID, userID uint64) ([]uint64, error)
|
||||
ChannelOverrides(ctx context.Context, channelID uint64) ([]OverrideData, error)
|
||||
GuildOwnerID(ctx context.Context, guildID uint64) (uint64, error)
|
||||
MemberTimeout(ctx context.Context, guildID, userID uint64) (bool, error)
|
||||
}
|
||||
|
||||
// RoleData и OverrideData описывают входные данные расчёта без зависимости
|
||||
// пакета прав от пакета хранения.
|
||||
type RoleData struct {
|
||||
ID uint64
|
||||
Permissions Permission
|
||||
IsDefault bool
|
||||
Position int
|
||||
}
|
||||
|
||||
type OverrideData struct {
|
||||
TargetType string // role | user
|
||||
TargetID uint64
|
||||
Allow Permission
|
||||
Deny Permission
|
||||
}
|
||||
|
||||
// Calculator вычисляет права и кэширует результат (AGENT.md 9.1).
|
||||
type Calculator struct {
|
||||
source Source
|
||||
cache *Cache
|
||||
}
|
||||
|
||||
func NewCalculator(source Source) *Calculator {
|
||||
return &Calculator{source: source, cache: NewCache(4096)}
|
||||
}
|
||||
|
||||
// InvalidateGuild сбрасывает кэш сервера при изменении ролей или оверрайдов.
|
||||
func (c *Calculator) InvalidateGuild(guildID uint64) { c.cache.InvalidateGuild(guildID) }
|
||||
|
||||
// InvalidateChannel сбрасывает кэш комнаты.
|
||||
func (c *Calculator) InvalidateChannel(channelID uint64) { c.cache.InvalidateChannel(channelID) }
|
||||
|
||||
// InvalidateAll полностью очищает кэш (после импорта или массовых изменений).
|
||||
func (c *Calculator) InvalidateAll() { c.cache.Clear() }
|
||||
|
||||
// Guild вычисляет права пользователя на сервере.
|
||||
func (c *Calculator) Guild(ctx context.Context, guildID, userID uint64, instanceAdmin bool) (Resolved, error) {
|
||||
if cached, ok := c.cache.GetGuild(guildID, userID); ok {
|
||||
cached.IsInstanceAdmin = instanceAdmin
|
||||
return cached, nil
|
||||
}
|
||||
|
||||
roles, err := c.source.GuildRoles(ctx, guildID)
|
||||
if err != nil {
|
||||
return Resolved{}, fmt.Errorf("load guild roles: %w", err)
|
||||
}
|
||||
memberRoleIDs, err := c.source.MemberRoleIDs(ctx, guildID, userID)
|
||||
if err != nil {
|
||||
return Resolved{}, fmt.Errorf("load member roles: %w", err)
|
||||
}
|
||||
ownerID, err := c.source.GuildOwnerID(ctx, guildID)
|
||||
if err != nil {
|
||||
return Resolved{}, fmt.Errorf("load guild owner: %w", err)
|
||||
}
|
||||
|
||||
resolved := Resolved{IsOwner: ownerID == userID, IsInstanceAdmin: instanceAdmin}
|
||||
assigned := make(map[uint64]bool, len(memberRoleIDs))
|
||||
for _, roleID := range memberRoleIDs {
|
||||
assigned[roleID] = true
|
||||
}
|
||||
|
||||
for _, role := range roles {
|
||||
if role.IsDefault || assigned[role.ID] {
|
||||
resolved.Guild |= role.Permissions
|
||||
}
|
||||
}
|
||||
if resolved.IsOwner {
|
||||
// Владелец сервера имеет ADMINISTRATOR неявно и не может его лишиться.
|
||||
resolved.Guild |= Administrator
|
||||
}
|
||||
timedOut, err := c.source.MemberTimeout(ctx, guildID, userID)
|
||||
if err != nil {
|
||||
return Resolved{}, fmt.Errorf("load member timeout: %w", err)
|
||||
}
|
||||
resolved.TimedOut = timedOut
|
||||
|
||||
c.cache.PutGuild(guildID, userID, resolved)
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
// RequireGuild проверяет право на сервере и возвращает ошибку доступа.
|
||||
func RequireGuild(resolved Resolved, permission Permission) error {
|
||||
if !resolved.Has(permission) {
|
||||
return fmt.Errorf("%w: %s", ErrDenied, Name(permission))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RequireChannel проверяет право в комнате и возвращает ошибку доступа.
|
||||
func RequireChannel(resolved Resolved, permission Permission) error {
|
||||
if !resolved.Can(permission) {
|
||||
return fmt.Errorf("%w: %s", ErrDenied, Name(permission))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ErrDenied — единая ошибка отсутствия прав (на HTTP-слое → perm.denied).
|
||||
var ErrDenied = fmt.Errorf("permission denied")
|
||||
|
||||
// HigherThan сообщает, что роль a выше роли b (AGENT.md 7.4).
|
||||
func HigherThan(a, b RoleData) bool { return a.Position > b.Position }
|
||||
|
||||
// OutranksRole проверяет, что актор может управлять ролью: его высшая роль
|
||||
// должна быть выше целевой, владелец — выше всех (AGENT.md 7.4).
|
||||
func OutranksRole(actorRoles []RoleData, targetRole RoleData, isOwner bool) bool {
|
||||
if isOwner {
|
||||
return true
|
||||
}
|
||||
for _, role := range actorRoles {
|
||||
if role.Position > targetRole.Position {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// OutranksMember проверяет иерархию участников: нельзя управлять тем, у кого
|
||||
// высшая роль не ниже роли актора (владелец — выше всех).
|
||||
func OutranksMember(actorRoles, targetRoles []RoleData, actorIsOwner, targetIsOwner bool) bool {
|
||||
if actorIsOwner {
|
||||
return !targetIsOwner || actorIsOwner
|
||||
}
|
||||
if targetIsOwner {
|
||||
return false
|
||||
}
|
||||
actorTop := topPosition(actorRoles)
|
||||
targetTop := topPosition(targetRoles)
|
||||
return actorTop > targetTop
|
||||
}
|
||||
|
||||
func topPosition(roles []RoleData) int {
|
||||
top := 0
|
||||
for _, role := range roles {
|
||||
if role.Position > top {
|
||||
top = role.Position
|
||||
}
|
||||
}
|
||||
return top
|
||||
}
|
||||
Reference in New Issue
Block a user