a5205cc054
- миграция 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 из константных шаблонов)
354 lines
12 KiB
Go
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
|
|
}
|