Files
glchat/internal/permissions/permissions.go
T
grendervill a5205cc054 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 из константных шаблонов)
2026-09-19 21:24:17 +03:00

354 lines
12 KiB
Go

// 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
}