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:
2026-09-19 21:24:17 +03:00
parent 06e313dcc6
commit a5205cc054
19 changed files with 3344 additions and 2 deletions
+149
View File
@@ -0,0 +1,149 @@
package permissions
import (
"container/list"
"sync"
)
// Cache — простой LRU-кэш вычисленных прав с инвалидацией при записи
// (AGENT.md 9.1). Ключи: сервер+пользователь и комната+пользователь.
type Cache struct {
mu sync.Mutex
capacity int
items map[guildKey]*list.Element
channels map[channelKey]*list.Element
order *list.List
}
type guildKey struct {
guildID uint64
userID uint64
}
type channelKey struct {
channelID uint64
userID uint64
}
type cacheEntry struct {
guild guildKey
channel channelKey
value Resolved
isChan bool
}
func NewCache(capacity int) *Cache {
if capacity <= 0 {
capacity = 1024
}
return &Cache{
capacity: capacity,
items: make(map[guildKey]*list.Element, capacity),
channels: make(map[channelKey]*list.Element, capacity),
order: list.New(),
}
}
func (c *Cache) GetGuild(guildID, userID uint64) (Resolved, bool) {
c.mu.Lock()
defer c.mu.Unlock()
element, ok := c.items[guildKey{guildID: guildID, userID: userID}]
if !ok {
return Resolved{}, false
}
c.order.MoveToFront(element)
return element.Value.(*cacheEntry).value, true
}
func (c *Cache) PutGuild(guildID, userID uint64, value Resolved) {
c.mu.Lock()
defer c.mu.Unlock()
key := guildKey{guildID: guildID, userID: userID}
if element, ok := c.items[key]; ok {
element.Value.(*cacheEntry).value = value
c.order.MoveToFront(element)
return
}
entry := &cacheEntry{guild: key, value: value}
c.items[key] = c.order.PushFront(entry)
c.evictLocked()
}
func (c *Cache) GetChannel(channelID, userID uint64) (Resolved, bool) {
c.mu.Lock()
defer c.mu.Unlock()
element, ok := c.channels[channelKey{channelID: channelID, userID: userID}]
if !ok {
return Resolved{}, false
}
c.order.MoveToFront(element)
return element.Value.(*cacheEntry).value, true
}
func (c *Cache) PutChannel(channelID, userID uint64, value Resolved) {
c.mu.Lock()
defer c.mu.Unlock()
key := channelKey{channelID: channelID, userID: userID}
if element, ok := c.channels[key]; ok {
element.Value.(*cacheEntry).value = value
c.order.MoveToFront(element)
return
}
entry := &cacheEntry{channel: key, value: value, isChan: true}
c.channels[key] = c.order.PushFront(entry)
c.evictLocked()
}
// InvalidateGuild сбрасывает права всех пользователей сервера.
func (c *Cache) InvalidateGuild(guildID uint64) {
c.mu.Lock()
defer c.mu.Unlock()
for key, element := range c.items {
if key.guildID == guildID {
c.order.Remove(element)
delete(c.items, key)
}
}
}
// InvalidateChannel сбрасывает кэш комнаты.
func (c *Cache) InvalidateChannel(channelID uint64) {
c.mu.Lock()
defer c.mu.Unlock()
for key, element := range c.channels {
if key.channelID == channelID {
c.order.Remove(element)
delete(c.channels, key)
}
}
}
func (c *Cache) Clear() {
c.mu.Lock()
defer c.mu.Unlock()
c.items = make(map[guildKey]*list.Element, c.capacity)
c.channels = make(map[channelKey]*list.Element, c.capacity)
c.order.Init()
}
func (c *Cache) Len() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.order.Len()
}
func (c *Cache) evictLocked() {
for c.order.Len() > c.capacity {
oldest := c.order.Back()
if oldest == nil {
return
}
entry := oldest.Value.(*cacheEntry)
if entry.isChan {
delete(c.channels, entry.channel)
} else {
delete(c.items, entry.guild)
}
c.order.Remove(oldest)
}
}
+78
View File
@@ -0,0 +1,78 @@
package permissions
import (
"context"
"fmt"
)
// Channel вычисляет права пользователя в конкретной комнате: базовые права
// сервера, затем оверрайды ролей, затем оверрайд пользователя (AGENT.md 6.2).
func (c *Calculator) Channel(ctx context.Context, guildID, channelID, userID uint64, instanceAdmin bool) (Resolved, error) {
if cached, ok := c.cache.GetChannel(channelID, userID); ok {
cached.IsInstanceAdmin = instanceAdmin
return cached, nil
}
resolved, err := c.Guild(ctx, guildID, userID, instanceAdmin)
if err != nil {
return Resolved{}, err
}
resolved.Channel = resolved.Guild
if resolved.IsInstanceAdmin || resolved.Guild.Has(Administrator) {
c.cache.PutChannel(channelID, userID, resolved)
return resolved, nil
}
overrides, err := c.source.ChannelOverrides(ctx, channelID)
if err != nil {
return Resolved{}, fmt.Errorf("load channel overrides: %w", err)
}
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)
}
assigned := make(map[uint64]bool, len(memberRoleIDs)+1)
for _, roleID := range memberRoleIDs {
assigned[roleID] = true
}
defaultRoleID := uint64(0)
for _, role := range roles {
if role.IsDefault {
defaultRoleID = role.ID
assigned[role.ID] = true
break
}
}
// Порядок как в AGENT.md 6.2: базовые права роли @user → оверрайды ролей →
// персональный оверрайд пользователя.
denied, allowed := Permission(0), Permission(0)
for _, override := range overrides {
if override.TargetType != "role" {
continue
}
if override.TargetID == defaultRoleID || assigned[override.TargetID] {
denied |= override.Deny
allowed |= override.Allow
}
}
computed := (resolved.Guild &^ denied) | allowed
// Персональный deny обязан перебить ролевой allow, поэтому применяется
// к уже вычисленному результату.
var userDeny, userAllow Permission
for _, override := range overrides {
if override.TargetType == "user" && override.TargetID == userID {
userDeny |= override.Deny
userAllow |= override.Allow
}
}
resolved.Channel = (computed &^ userDeny) | userAllow
c.cache.PutChannel(channelID, userID, resolved)
return resolved, nil
}
+255
View File
@@ -0,0 +1,255 @@
package permissions_test
import (
"context"
"errors"
"path/filepath"
"testing"
"glchat/internal/database"
"glchat/internal/permissions"
"glchat/internal/source"
"glchat/internal/store"
)
// newTestStore поднимает реальную SQLite со всеми миграциями: так проверяются
// и SQL-запросы, и связка store → permissions (AGENT.md §11.1).
func newTestStore(t *testing.T) *store.Store {
t.Helper()
db, err := database.Open(context.Background(), database.Options{
Path: filepath.Join(t.TempDir(), "glchat.db"),
ReadPool: 2,
Migrate: true,
})
if err != nil {
t.Fatalf("open database: %v", err)
}
t.Cleanup(func() {
if err := db.Close(); err != nil {
t.Errorf("close database: %v", err)
}
})
return store.New(db)
}
func createUser(t *testing.T, st *store.Store, username string) *store.User {
t.Helper()
user, err := st.CreateUser(context.Background(), store.CreateUserParams{
Username: username,
DisplayName: username,
EmailEnc: "v1:encrypted-" + username,
EmailIndex: "index-" + username,
PasswordHash: "argon2id$test",
})
if err != nil {
t.Fatalf("create user %s: %v", username, err)
}
return user
}
// buildGuild создаёт сервер с ролями по умолчанию: «Администратор» и «Пользователь».
func buildGuild(t *testing.T, st *store.Store, owner *store.User) (*store.Guild, *store.Role, *store.Role) {
t.Helper()
ctx := context.Background()
guild, err := st.CreateGuild(ctx, store.CreateGuildParams{Name: "Test Guild", OwnerID: owner.ID, IsMain: true})
if err != nil {
t.Fatalf("create guild: %v", err)
}
adminRole, err := st.CreateRole(ctx, store.CreateRoleParams{
GuildID: guild.ID, Name: "Администратор", Permissions: uint64(permissions.AllPermissions), Position: 100,
})
if err != nil {
t.Fatalf("create admin role: %v", err)
}
userRole, err := st.CreateRole(ctx, store.CreateRoleParams{
GuildID: guild.ID, Name: "Пользователь", Permissions: uint64(permissions.DefaultUserPermissions),
Position: 0, IsDefault: true, Mentionable: true,
})
if err != nil {
t.Fatalf("create default role: %v", err)
}
if _, err := st.AddGuildMember(ctx, guild.ID, owner.ID, ""); err != nil {
t.Fatalf("add owner as member: %v", err)
}
return guild, adminRole, userRole
}
func TestEngineAgainstRealDatabase(t *testing.T) {
ctx := context.Background()
st := newTestStore(t)
calculator := permissions.NewCalculator(source.New(st))
owner := createUser(t, st, "owner")
member := createUser(t, st, "member")
moderator := createUser(t, st, "moderator")
guild, adminRole, userRole := buildGuild(t, st, owner)
for _, user := range []*store.User{member, moderator} {
if _, err := st.AddGuildMember(ctx, guild.ID, user.ID, ""); err != nil {
t.Fatalf("add member: %v", err)
}
}
moderatorRole, err := st.CreateRole(ctx, store.CreateRoleParams{
GuildID: guild.ID, Name: "Модератор",
Permissions: uint64(permissions.ViewGuild | permissions.ViewChannel | permissions.SendMessages |
permissions.ManageMessages | permissions.KickMembers),
Position: 50,
})
if err != nil {
t.Fatalf("create moderator role: %v", err)
}
if err := st.AssignRole(ctx, guild.ID, moderator.ID, moderatorRole.ID); err != nil {
t.Fatalf("assign moderator role: %v", err)
}
channel, err := st.CreateChannel(ctx, store.CreateChannelParams{
GuildID: &guild.ID, Type: store.ChannelText, Name: "общий",
})
if err != nil {
t.Fatalf("create channel: %v", err)
}
private, err := st.CreateChannel(ctx, store.CreateChannelParams{
GuildID: &guild.ID, Type: store.ChannelText, Name: "приватный",
})
if err != nil {
t.Fatalf("create private channel: %v", err)
}
// Приватный канал: @user не видит, модератор — видит.
if err := st.SetChannelOverride(ctx, store.ChannelOverride{
ChannelID: private.ID, TargetType: "role", TargetID: userRole.ID,
Deny: uint64(permissions.ViewChannel | permissions.ReadMessageHistory),
}); err != nil {
t.Fatalf("set override: %v", err)
}
if err := st.SetChannelOverride(ctx, store.ChannelOverride{
ChannelID: private.ID, TargetType: "role", TargetID: moderatorRole.ID,
Allow: uint64(permissions.ViewChannel | permissions.ReadMessageHistory),
}); err != nil {
t.Fatalf("set moderator override: %v", err)
}
calculator.InvalidateAll()
t.Run("member sees public channel only", func(t *testing.T) {
public, err := calculator.Channel(ctx, guild.ID, channel.ID, member.ID, false)
if err != nil {
t.Fatalf("channel permissions: %v", err)
}
if !public.Can(permissions.SendMessages) {
t.Fatal("member must send messages in the public channel")
}
hidden, err := calculator.Channel(ctx, guild.ID, private.ID, member.ID, false)
if err != nil {
t.Fatalf("private channel permissions: %v", err)
}
if hidden.CanViewChannel() {
t.Fatal("member must not see the private channel")
}
if err := permissions.RequireChannel(hidden, permissions.ViewChannel); !errors.Is(err, permissions.ErrDenied) {
t.Fatalf("RequireChannel = %v, want ErrDenied", err)
}
})
t.Run("moderator sees private channel and can moderate", func(t *testing.T) {
visible, err := calculator.Channel(ctx, guild.ID, private.ID, moderator.ID, false)
if err != nil {
t.Fatalf("channel permissions: %v", err)
}
if !visible.CanViewChannel() || !visible.Can(permissions.ReadMessageHistory) {
t.Fatal("moderator must see the private channel")
}
if !visible.Has(permissions.KickMembers) {
t.Fatal("moderator must keep guild-level KICK_MEMBERS")
}
if visible.Can(permissions.BanMembers) {
t.Fatal("moderator must not receive BAN_MEMBERS")
}
})
t.Run("owner has administrator everywhere", func(t *testing.T) {
resolved, err := calculator.Channel(ctx, guild.ID, private.ID, owner.ID, false)
if err != nil {
t.Fatalf("channel permissions: %v", err)
}
if !resolved.IsOwner || !resolved.Can(permissions.Administrator) {
t.Fatal("owner must have ADMINISTRATOR")
}
if !resolved.CanViewChannel() {
t.Fatal("owner must see any channel")
}
})
t.Run("instance admin bypasses privacy", func(t *testing.T) {
stranger := createUser(t, st, "instance-admin")
resolved, err := calculator.Channel(ctx, guild.ID, private.ID, stranger.ID, true)
if err != nil {
t.Fatalf("channel permissions: %v", err)
}
if !resolved.CanViewChannel() || !resolved.Can(permissions.ManageRoles) {
t.Fatal("instance admin must bypass privacy and permissions")
}
})
t.Run("timeout blocks writing", func(t *testing.T) {
timeout := store.TimeoutFromNow(1)
if err := st.UpdateGuildMember(ctx, guild.ID, member.ID, nil, &timeout); err != nil {
t.Fatalf("set timeout: %v", err)
}
calculator.InvalidateAll()
resolved, err := calculator.Channel(ctx, guild.ID, channel.ID, member.ID, false)
if err != nil {
t.Fatalf("channel permissions: %v", err)
}
if !resolved.TimedOut {
t.Fatal("timeout must be reflected in resolved permissions")
}
if resolved.Can(permissions.SendMessages) {
t.Fatal("timed out member must not write")
}
if !resolved.Can(permissions.ViewChannel) {
t.Fatal("timed out member must still read")
}
})
t.Run("administrator role ignores overrides", func(t *testing.T) {
if err := st.AssignRole(ctx, guild.ID, member.ID, adminRole.ID); err != nil {
t.Fatalf("assign admin role: %v", err)
}
calculator.InvalidateAll()
resolved, err := calculator.Channel(ctx, guild.ID, private.ID, member.ID, false)
if err != nil {
t.Fatalf("channel permissions: %v", err)
}
if !resolved.CanViewChannel() {
t.Fatal("administrator role must ignore deny overrides")
}
})
}
func TestRoleHierarchyHelpersWithRealRoles(t *testing.T) {
ctx := context.Background()
st := newTestStore(t)
owner := createUser(t, st, "owner2")
guild, adminRole, userRole := buildGuild(t, st, owner)
moderatorRole, err := st.CreateRole(ctx, store.CreateRoleParams{
GuildID: guild.ID, Name: "Модератор", Position: 50,
Permissions: uint64(permissions.KickMembers),
})
if err != nil {
t.Fatalf("create role: %v", err)
}
toData := func(role *store.Role) permissions.RoleData {
return permissions.RoleData{ID: role.ID, Permissions: permissions.Permission(role.Permissions), Position: role.Position}
}
if !permissions.OutranksRole([]permissions.RoleData{toData(moderatorRole)}, toData(userRole), false) {
t.Fatal("moderator must outrank the default role")
}
if permissions.OutranksRole([]permissions.RoleData{toData(moderatorRole)}, toData(adminRole), false) {
t.Fatal("moderator must not outrank the administrator role")
}
if !permissions.OutranksRole(nil, toData(adminRole), true) {
t.Fatal("owner must outrank every role")
}
}
+353
View File
@@ -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
}
+318
View File
@@ -0,0 +1,318 @@
package permissions
import (
"context"
"errors"
"testing"
)
// fakeSource — управляемый источник данных для тестов прав.
type fakeSource struct {
roles map[uint64][]RoleData
memberRoles map[uint64]map[uint64][]uint64
overrides map[uint64][]OverrideData
owners map[uint64]uint64
timeouts map[uint64]map[uint64]bool
}
func newFakeSource() *fakeSource {
return &fakeSource{
roles: map[uint64][]RoleData{},
memberRoles: map[uint64]map[uint64][]uint64{},
overrides: map[uint64][]OverrideData{},
owners: map[uint64]uint64{},
timeouts: map[uint64]map[uint64]bool{},
}
}
func (f *fakeSource) GuildRoles(_ context.Context, guildID uint64) ([]RoleData, error) {
return f.roles[guildID], nil
}
func (f *fakeSource) MemberRoleIDs(_ context.Context, guildID, userID uint64) ([]uint64, error) {
if byUser, ok := f.memberRoles[guildID]; ok {
return byUser[userID], nil
}
return nil, nil
}
func (f *fakeSource) ChannelOverrides(_ context.Context, channelID uint64) ([]OverrideData, error) {
return f.overrides[channelID], nil
}
func (f *fakeSource) GuildOwnerID(_ context.Context, guildID uint64) (uint64, error) {
return f.owners[guildID], nil
}
func (f *fakeSource) MemberTimeout(_ context.Context, guildID, userID uint64) (bool, error) {
if byUser, ok := f.timeouts[guildID]; ok {
return byUser[userID], nil
}
return false, nil
}
const (
guildID = uint64(100)
ownerID = uint64(1)
memberID = uint64(2)
adminID = uint64(3)
defaultID = uint64(10)
adminRole = uint64(11)
moderatorRole = uint64(12)
channelID = uint64(500)
)
func fixture() (*fakeSource, *Calculator) {
source := newFakeSource()
source.roles[guildID] = []RoleData{
{ID: adminRole, Permissions: Administrator, Position: 100},
{ID: moderatorRole, Permissions: ManageMessages | KickMembers | ViewGuild | ViewChannel | SendMessages, Position: 50},
{ID: defaultID, Permissions: DefaultUserPermissions, IsDefault: true, Position: 0},
}
source.memberRoles[guildID] = map[uint64][]uint64{
memberID: {},
adminID: {adminRole},
ownerID: {},
}
source.owners[guildID] = ownerID
return source, NewCalculator(source)
}
func TestDefaultRoleProvidesBasePermissions(t *testing.T) {
_, calculator := fixture()
resolved, err := calculator.Guild(context.Background(), guildID, memberID, false)
if err != nil {
t.Fatalf("Guild: %v", err)
}
if !resolved.Has(SendMessages) || !resolved.Has(ViewChannel) {
t.Fatalf("default role permissions not applied: %s", resolved.Guild)
}
if resolved.Has(KickMembers) {
t.Fatal("member must not receive moderator permissions")
}
}
func TestAssignedRoleAddsPermissions(t *testing.T) {
source, calculator := fixture()
source.memberRoles[guildID][memberID] = []uint64{moderatorRole}
calculator.InvalidateAll()
resolved, err := calculator.Guild(context.Background(), guildID, memberID, false)
if err != nil {
t.Fatalf("Guild: %v", err)
}
if !resolved.Has(KickMembers) || !resolved.Has(ManageMessages) {
t.Fatalf("assigned role permissions missing: %s", resolved.Guild)
}
}
func TestAdministratorGrantsEverything(t *testing.T) {
_, calculator := fixture()
resolved, err := calculator.Guild(context.Background(), guildID, adminID, false)
if err != nil {
t.Fatalf("Guild: %v", err)
}
if !resolved.Has(Administrator) {
t.Fatal("administrator bit must be set")
}
if !resolved.Has(BanMembers) || !resolved.Has(ManageGuild) {
t.Fatal("ADMINISTRATOR must imply every permission")
}
}
func TestOwnerAlwaysHasAdministrator(t *testing.T) {
source := newFakeSource()
// Владелец без ролей и без ADMINISTRATOR у роли @user.
source.roles[guildID] = []RoleData{{ID: defaultID, Permissions: ViewGuild, IsDefault: true}}
source.owners[guildID] = ownerID
calculator := NewCalculator(source)
resolved, err := calculator.Guild(context.Background(), guildID, ownerID, false)
if err != nil {
t.Fatalf("Guild: %v", err)
}
if !resolved.IsOwner || !resolved.Has(Administrator) {
t.Fatal("owner must implicitly receive ADMINISTRATOR")
}
}
func TestInstanceAdminBypassesEverything(t *testing.T) {
_, calculator := fixture()
resolved, err := calculator.Channel(context.Background(), guildID, channelID, memberID, true)
if err != nil {
t.Fatalf("Channel: %v", err)
}
// Инстанс-админ обходит и приватность комнаты, и продуктовые лимиты (AGENT.md 7.19).
if !resolved.Can(BanMembers) || !resolved.CanViewChannel() {
t.Fatal("instance admin must bypass permission checks")
}
if err := RequireChannel(resolved, Administrator); err != nil {
t.Fatalf("instance admin must satisfy Administrator: %v", err)
}
}
func TestChannelDenyOverrideRemovesPermission(t *testing.T) {
source, calculator := fixture()
source.overrides[channelID] = []OverrideData{
{TargetType: "role", TargetID: defaultID, Deny: SendMessages},
}
resolved, err := calculator.Channel(context.Background(), guildID, channelID, memberID, false)
if err != nil {
t.Fatalf("Channel: %v", err)
}
if resolved.Can(SendMessages) {
t.Fatal("deny override must remove the permission")
}
if !resolved.Can(ViewChannel) {
t.Fatal("other permissions must survive the override")
}
}
func TestChannelAllowOverrideGrantsPermission(t *testing.T) {
source, calculator := fixture()
source.overrides[channelID] = []OverrideData{
{TargetType: "user", TargetID: memberID, Deny: ViewChannel},
{TargetType: "role", TargetID: defaultID, Allow: ConnectVoice},
}
resolved, err := calculator.Channel(context.Background(), guildID, channelID, memberID, false)
if err != nil {
t.Fatalf("Channel: %v", err)
}
if resolved.Can(ViewChannel) {
t.Fatal("personal deny must hide the channel")
}
if !resolved.Can(ConnectVoice) {
t.Fatal("role allow must add the permission")
}
}
func TestUserOverrideWinsOverRoleOverride(t *testing.T) {
source, calculator := fixture()
source.overrides[channelID] = []OverrideData{
{TargetType: "role", TargetID: moderatorRole, Allow: SendMessages},
{TargetType: "user", TargetID: memberID, Deny: SendMessages},
}
source.memberRoles[guildID][memberID] = []uint64{moderatorRole}
calculator.InvalidateAll()
resolved, err := calculator.Channel(context.Background(), guildID, channelID, memberID, false)
if err != nil {
t.Fatalf("Channel: %v", err)
}
if resolved.Can(SendMessages) {
t.Fatal("user-level deny must be applied after role-level allow")
}
}
func TestAdministratorIgnoresChannelOverrides(t *testing.T) {
source, calculator := fixture()
source.overrides[channelID] = []OverrideData{
{TargetType: "role", TargetID: adminRole, Deny: ViewChannel | SendMessages},
}
resolved, err := calculator.Channel(context.Background(), guildID, channelID, adminID, false)
if err != nil {
t.Fatalf("Channel: %v", err)
}
if !resolved.CanViewChannel() || !resolved.Can(SendMessages) {
t.Fatal("ADMINISTRATOR must ignore channel overrides")
}
}
func TestTimeoutBlocksWritingButNotReading(t *testing.T) {
source, calculator := fixture()
source.timeouts[guildID] = map[uint64]bool{memberID: true}
calculator.InvalidateAll()
resolved, err := calculator.Channel(context.Background(), guildID, channelID, memberID, false)
if err != nil {
t.Fatalf("Channel: %v", err)
}
if resolved.Can(SendMessages) || resolved.Can(AddReactions) {
t.Fatal("timed out member must not be able to write")
}
if !resolved.Can(ViewChannel) || !resolved.Can(ReadMessageHistory) {
t.Fatal("timed out member must still read the channel")
}
}
func TestRequireHelpersReturnDenied(t *testing.T) {
_, calculator := fixture()
resolved, err := calculator.Guild(context.Background(), guildID, memberID, false)
if err != nil {
t.Fatalf("Guild: %v", err)
}
if err := RequireGuild(resolved, SendMessages); err != nil {
t.Fatalf("RequireGuild(SendMessages): %v", err)
}
if err := RequireGuild(resolved, BanMembers); !errors.Is(err, ErrDenied) {
t.Fatalf("RequireGuild(BanMembers) = %v, want ErrDenied", err)
}
}
func TestOutranksRoleAndMember(t *testing.T) {
top := RoleData{ID: adminRole, Position: 100}
middle := RoleData{ID: moderatorRole, Position: 50}
bottom := RoleData{ID: defaultID, Position: 0}
if !OutranksRole([]RoleData{middle}, bottom, false) {
t.Fatal("moderator must outrank the default role")
}
if OutranksRole([]RoleData{middle}, top, false) {
t.Fatal("moderator must not outrank the administrator role")
}
if !OutranksRole(nil, top, true) {
t.Fatal("owner must outrank any role")
}
if !OutranksMember([]RoleData{top}, []RoleData{middle}, false, false) {
t.Fatal("higher role must outrank the lower member")
}
if OutranksMember([]RoleData{middle}, []RoleData{top}, false, false) {
t.Fatal("lower role must not manage the higher member")
}
if OutranksMember([]RoleData{top}, nil, false, true) {
t.Fatal("nobody except the owner may manage the owner")
}
}
func TestPermissionNamesRoundTrip(t *testing.T) {
for _, permission := range AllPermissionsList() {
name := Name(permission)
parsed, ok := ByName(name)
if !ok || parsed != permission {
t.Fatalf("round trip failed for %s (%d)", name, permission)
}
}
if _, ok := ByName("NOT_A_PERMISSION"); ok {
t.Fatal("unknown permission name must not parse")
}
if len(AllPermissionsList()) != 37 {
t.Fatalf("expected 37 permissions, got %d", len(AllPermissionsList()))
}
}
func TestCacheInvalidation(t *testing.T) {
source, calculator := fixture()
first, err := calculator.Guild(context.Background(), guildID, memberID, false)
if err != nil {
t.Fatalf("Guild: %v", err)
}
if first.Has(KickMembers) {
t.Fatal("member must not have KICK_MEMBERS initially")
}
// Меняем роли в источнике без инвалидации: кэш должен отдать старое значение.
source.memberRoles[guildID][memberID] = []uint64{moderatorRole}
cached, _ := calculator.Guild(context.Background(), guildID, memberID, false)
if cached.Has(KickMembers) {
t.Fatal("cache must serve the stale value until invalidation")
}
calculator.InvalidateGuild(guildID)
updated, err := calculator.Guild(context.Background(), guildID, memberID, false)
if err != nil {
t.Fatalf("Guild after invalidation: %v", err)
}
if !updated.Has(KickMembers) {
t.Fatal("after invalidation the new role must be visible")
}
}