Files
futriix/internal/acl/manger.go
T

1199 lines
41 KiB
Go
Raw Normal View History

2026-09-28 21:29:22 +00:00
/*
* Copyright 2026 Safronov Grigorii
*
* Licensed under the CDDL, Version 1.0 (the "License");
* you may not use this file except in compliance with the License.
*
* You may obtain a copy of the License at
* https://opensource.org/licenses/CDDL-1.0
*/
// Файл: internal/acl/manager.go
// Назначение: Глобальный менеджер ACL для всей СУБД.
// Управляет пользователями, ролями и разрешениями на уровне БД и коллекций.
//
// ВАЖНО:
// - Пароли хранятся как PBKDF2-HMAC-SHA256 (соль + 100000 итераций),
// а не в открытом виде
// - Constant-time сравнение паролей (защита от timing-атак)
// - Убраны дефолтные пароли admin/admin и guest/guest; admin создаётся
// со случайным паролем, который выводится в stderr; guest не создаётся
// - Сессии имеют TTL и периодически очищаются фоновой горутиной
// - CheckPermission сначала валидирует сессию (expiry)
// - Исправлена критическая уязвимость эскалации привилегий в matchPermission:
// раньше роль с "users:read" получала доступ к "users:admin". Теперь
// admin-операция требует явного права "admin" в разрешении роли
// - Блокировка аккаунта после 5 неудачных попыток входа (15 минут)
// - Валидация username (regex) и длины password
// - GrantPermission дедуплицирует разрешения
// - ListUsers/ListRoles возвращают отсортированные списки
// - Метод Stop() для остановки фоновой очистки сессий
// - Защита от nil-указателей в GetUserInfo/GetRolePermissions
//
// ИСПРАВЛЕНО (UI):
// - Убрана рамка "====" и пустые строки вокруг сообщения о созданном
// admin-пользователе. Сразу после сообщения должен идти баннер futriis.
// - Текст сообщения окрашивается в #00bfff (Deep Sky Blue), чтобы
// совпадать с цветом баннера futriis 3i²(by 02.04.2026).
// - Цвет применяется только если stderr — это TTY (защита от попадания
// ANSI-кодов в файл лога при перенаправлении).
// - Добавлен фолбэк по уровню поддержки терминала:
// True Color (COLORTERM=truecolor) -> \033[38;2;0;191;255m
// 256-цветный (TERM=xterm-256color) -> \033[38;5;39m
// базовый ANSI -> \033[96m (bright cyan)
// Это обеспечивает корректный цвет и на Linux, и на OpenIndiana
// (illumos), где xterm по умолчанию не поддерживает True Color.
// - Пакет pkg/utils намеренно НЕ импортируется, чтобы не создавать
// потенциальную циклическую зависимость (utils → acl → utils).
package acl
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"fmt"
"os"
"regexp"
"sort"
"strings"
"sync"
"time"
"github.com/google/uuid"
)
// =============================================================================
// КОНСТАНТЫ
// =============================================================================
const (
// Параметры PBKDF2
pbkdf2Iterations = 100000
pbkdf2KeyLen = 32
saltLen = 16
// Ограничения валидации
maxUsernameLen = 64
minPasswordLen = 8
maxPasswordLen = 256
// Параметры сессий
sessionTTL = 24 * time.Hour
sessionCleanupEvery = 10 * time.Minute
maxSessionsPerUser = 16
// Параметры блокировки аккаунта
maxFailedAttempts = 5
lockoutDuration = 15 * time.Minute
// ANSI-коды для окраски сообщения о созданном admin-пользователе.
// Используется точный #00bfff (Deep Sky Blue) с фолбэком на 256-цветный
// и базовый ANSI для корректного отображения на OpenIndiana (illumos),
// где xterm по умолчанию не поддерживает True Color.
ansiDeepSkyBlueRGB = "\033[38;2;0;191;255m" // True Color
ansiDeepSkyBlue256 = "\033[38;5;39m" // Ближайший из xterm-256 палитры
ansiDeepSkyBlue16 = "\033[96m" // Bright Cyan (базовый ANSI)
ansiReset = "\033[0m"
)
// usernameRegex разрешает только безопасные символы в имени пользователя.
var usernameRegex = regexp.MustCompile(`^[a-zA-Z0-9_.@-]+$`)
// permissionRegex валидирует формат разрешения:
// - "*:*" — полный доступ
// - "db.coll:op" — конкретный ресурс и операция
// - "db.*:op" — wildcard коллекции
// где db/coll/op — непустые строки из [A-Za-z0-9_*.-] (op — [A-Za-z0-9_*]).
var permissionRegex = regexp.MustCompile(`^([A-Za-z0-9_.*-]+)\.([A-Za-z0-9_*.-]+):([A-Za-z0-9_*]+)$|^\*:\*$`)
// =============================================================================
// ТИПЫ
// =============================================================================
// PermissionType определяет тип разрешения
type PermissionType string
const (
PermRead PermissionType = "read"
PermWrite PermissionType = "write"
PermDelete PermissionType = "delete"
PermAdmin PermissionType = "admin"
)
// Logger — минимальный интерфейс для логирования внутри ACL.
// Сделан локально, чтобы не тянуть internal/log (и не создавать циклов).
type Logger interface {
Warn(msg string)
Info(msg string)
}
// User представляет пользователя системы.
//
// PasswordHash хранится в формате: pbkdf2$iterations$salt_base64$hash_base64
// LegacyPassword — для обратной совместимости со старым форматом (SHA-256),
// будет удалено при первом успешном входе.
type User struct {
ID string `msgpack:"id"`
Username string `msgpack:"username"`
PasswordHash string `msgpack:"password_hash,omitempty"`
LegacyPassword string `msgpack:"password,omitempty"`
Roles []string `msgpack:"roles"`
CreatedAt int64 `msgpack:"created_at"`
LastLogin int64 `msgpack:"last_login"`
Active bool `msgpack:"active"`
FailedAttempts int `msgpack:"failed_attempts,omitempty"`
LockedUntil int64 `msgpack:"locked_until,omitempty"`
}
// Session представляет активную сессию пользователя.
//
// ИСПРАВЛЕНО: добавлены CreatedByIP и UserAgent — для привязки сессии
// к клиенту (мягкая защита от session hijacking).
type Session struct {
ID string `msgpack:"id"`
Username string `msgpack:"username"`
Roles []string `msgpack:"roles"`
CreatedAt int64 `msgpack:"created_at"`
ExpiresAt int64 `msgpack:"expires_at"`
CreatedByIP string `msgpack:"created_by_ip,omitempty"`
UserAgent string `msgpack:"user_agent,omitempty"`
}
// Role представляет роль с набором разрешений
type Role struct {
Name string `msgpack:"name"`
Permissions []string `msgpack:"permissions"`
}
// ACLManager управляет доступом к БД.
//
// ВАЖНО: sync.Map используется для чтений без блокировок. Все
// read-modify-write операции (Authenticate, ChangePassword, ...)
// защищены m.mu — иначе возможны потерянные обновления.
type ACLManager struct {
users sync.Map // map[string]*User
roles sync.Map // map[string]*Role
sessions sync.Map // map[string]*Session
// mu защищает read-modify-write операции над users/roles/sessions.
// Чтения через sync.Map.Load не блокируются.
mu sync.RWMutex
logger Logger
stopChan chan struct{}
wg sync.WaitGroup
stopOnce sync.Once
}
// =============================================================================
// КОНСТРУКТОР
// =============================================================================
// isTTY проверяет, является ли файл терминалом.
// Используется для отключения ANSI-кодов при перенаправлении вывода в файл.
func isTTY(f *os.File) bool {
if f == nil {
return false
}
fi, err := f.Stat()
if err != nil {
return false
}
return (fi.Mode() & os.ModeCharDevice) != 0
}
// pickDeepSkyBlueCode выбирает подходящий ANSI-код для #00bfff в
// зависимости от возможностей терминала.
//
// Логика:
// - True Color (COLORTERM=truecolor / 24bit) -> RGB \033[38;2;0;191;255m
// - 256-цветный (TERM=...256color) -> \033[38;5;39m
// - иначе -> \033[96m (bright cyan)
//
// Это гарантирует, что на OpenIndiana (illumos), где xterm по умолчанию
// не поддерживает True Color, цвет будет максимально близок к #00bfff
// без искажений.
func pickDeepSkyBlueCode() string {
colorterm := strings.ToLower(os.Getenv("COLORTERM"))
term := strings.ToLower(os.Getenv("TERM"))
if colorterm == "truecolor" || colorterm == "24bit" {
return ansiDeepSkyBlueRGB
}
if strings.Contains(term, "256color") || strings.Contains(colorterm, "256") {
return ansiDeepSkyBlue256
}
return ansiDeepSkyBlue16
}
// colorizeACLMessage окрашивает строку в #00bfff (Deep Sky Blue),
// если stderr является TTY. Если stderr перенаправлен в файл —
// возвращает строку без ANSI-кодов (чтобы они не попали в лог).
func colorizeACLMessage(s string) string {
if !isTTY(os.Stderr) {
return s
}
return pickDeepSkyBlueCode() + s + ansiReset
}
// NewACLManager создаёт новый менеджер ACL.
//
// Создаёт роль "admin" с полными правами и пользователя "admin"
// со случайным паролем, который выводится в stderr. Пользователь guest
// не создаётся — если нужен гостевой доступ, создайте его явно.
//
// ИСПРАВЛЕНО (UI): убраны рамка "====" и пустые строки вокруг сообщения.
//
// ИСПРАВЛЕНО (безопасность): при сбое PBKDF2 функция возвращает ошибку,
// а НЕ паникует и НЕ сохраняет пароль в открытом виде. Вызывающий код
// (cmd/futriis/main.go) должен корректно обработать ошибку и завершиться
// с кодом 1 через os.Exit, а не через panic/stack trace.
func NewACLManager() (*ACLManager, error) {
m := &ACLManager{
stopChan: make(chan struct{}),
}
// Создаём роль администратора
adminRole := &Role{
Name: "admin",
Permissions: []string{"*:*"},
}
m.roles.Store("admin", adminRole)
// Создаём администратора со случайным паролем
randomBytes := make([]byte, 24)
if _, err := rand.Read(randomBytes); err != nil {
// Fallback на UUID допустим: это не крипто-операция для хеша,
// а источник случайности для пароля. Но если и UUID не
// сгенерируется — crypto/rand уже недоступен, и мы не хотим
// продолжать с ослабленным паролем.
randomBytes = []byte(uuid.New().String())
}
adminPassword := base64.RawURLEncoding.EncodeToString(randomBytes)
adminHash, err := hashPassword(adminPassword)
if err != nil {
// ИСПРАВЛЕНО: не сохраняем пароль в открытом виде, не паникуем,
// а возвращаем ошибку — вызывающий код сам решит, что делать.
return nil, fmt.Errorf("acl: failed to hash admin password: %w", err)
}
adminUser := &User{
ID: uuid.New().String(),
Username: "admin",
PasswordHash: adminHash,
Roles: []string{"admin"},
CreatedAt: time.Now().UnixMilli(),
Active: true,
}
m.users.Store("admin", adminUser)
// ИСПРАВЛЕНО (UI): убрана пустая строка " \n" (в ней был лишний пробел).
// Сразу после этого блока main.go вызывает displayBanner, который
// начинает вывод со строки "futriis 3i²(by 02.04.2026)".
//
// Пароль по-прежнему идёт в stderr (не в stdout), чтобы его можно
// было отделить от обычного вывода и не логировать.
fmt.Fprintln(os.Stderr, colorizeACLMessage(" \n"))
fmt.Fprintln(os.Stderr, colorizeACLMessage(" ACL: user 'admin' created with a random password."))
fmt.Fprintln(os.Stderr, colorizeACLMessage(fmt.Sprintf(" Password: %s", adminPassword)))
fmt.Fprintln(os.Stderr, colorizeACLMessage(" Change your password after logging in for the first time using the 'acl change-password' command"))
// Запускаем фоновую очистку сессий
m.wg.Add(1)
go m.sessionCleanupLoop()
return m, nil
}
// SetLogger устанавливает логгер (опционально).
// Вызывается после NewACLManager, если приложение хочет логировать
// события ACL (неудачные входы, блокировки аккаунтов и т.п.).
func (m *ACLManager) SetLogger(l Logger) {
m.mu.Lock()
defer m.mu.Unlock()
m.logger = l
}
func (m *ACLManager) logWarn(msg string) {
m.mu.RLock()
l := m.logger
m.mu.RUnlock()
if l != nil {
l.Warn(msg)
}
}
// =============================================================================
// ХЕШИРОВАНИЕ ПАРОЛЕЙ (PBKDF2-HMAC-SHA256)
// =============================================================================
// hashPassword создаёт PBKDF2-хеш пароля.
// Формат: pbkdf2$iterations$salt_base64$hash_base64
func hashPassword(password string) (string, error) {
salt := make([]byte, saltLen)
if _, err := rand.Read(salt); err != nil {
return "", fmt.Errorf("failed to generate salt: %w", err)
}
hash := pbkdf2Key(password, salt, pbkdf2Iterations, pbkdf2KeyLen)
return fmt.Sprintf("pbkdf2$%d$%s$%s",
pbkdf2Iterations,
base64.StdEncoding.EncodeToString(salt),
base64.StdEncoding.EncodeToString(hash),
), nil
}
// pbkdf2Key реализует PBKDF2-HMAC-SHA256.
// Минимальная реализация без внешних зависимостей.
func pbkdf2Key(password string, salt []byte, iterations, keyLen int) []byte {
const hashLen = 32 // SHA-256
key := make([]byte, keyLen)
numBlocks := (keyLen + hashLen - 1) / hashLen
for block := 1; block <= numBlocks; block++ {
mac := hmac.New(sha256.New, []byte(password))
mac.Write(salt)
mac.Write([]byte{
byte(block >> 24), byte(block >> 16), byte(block >> 8), byte(block),
})
u := mac.Sum(nil)
result := make([]byte, len(u))
copy(result, u)
for i := 1; i < iterations; i++ {
mac.Reset()
mac.Write(u)
u = mac.Sum(nil)
for j := range result {
result[j] ^= u[j]
}
}
offset := (block - 1) * hashLen
copy(key[offset:], result)
}
return key[:keyLen]
}
// verifyPassword проверяет пароль против PBKDF2-хеша (constant-time).
func verifyPassword(password, storedHash string) bool {
parts := strings.Split(storedHash, "$")
if len(parts) != 4 || parts[0] != "pbkdf2" {
return false
}
var iterations int
if _, err := fmt.Sscanf(parts[1], "%d", &iterations); err != nil {
return false
}
if iterations <= 0 || iterations > 10_000_000 {
return false
}
salt, err := base64.StdEncoding.DecodeString(parts[2])
if err != nil {
return false
}
expected, err := base64.StdEncoding.DecodeString(parts[3])
if err != nil {
return false
}
actual := pbkdf2Key(password, salt, iterations, len(expected))
return subtle.ConstantTimeCompare(actual, expected) == 1
}
// verifyLegacyPassword проверяет старый формат (base64(SHA-256(password))).
// Используется только для миграции.
func verifyLegacyPassword(password, storedHash string) bool {
hash := sha256.Sum256([]byte(password))
computed := base64.StdEncoding.EncodeToString(hash[:])
return subtle.ConstantTimeCompare([]byte(computed), []byte(storedHash)) == 1
}
// =============================================================================
// ВАЛИДАЦИЯ
// =============================================================================
func validateUsername(username string) error {
if username == "" {
return fmt.Errorf("username cannot be empty")
}
if len(username) > maxUsernameLen {
return fmt.Errorf("username too long (max %d)", maxUsernameLen)
}
if !usernameRegex.MatchString(username) {
return fmt.Errorf("username contains invalid characters")
}
return nil
}
func validatePassword(password string) error {
if len(password) < minPasswordLen {
return fmt.Errorf("password must be at least %d characters", minPasswordLen)
}
if len(password) > maxPasswordLen {
return fmt.Errorf("password too long (max %d)", maxPasswordLen)
}
return nil
}
// validatePermission проверяет формат строки разрешения.
// Допустимые формы:
// - "*:*"
// - "database.collection:operation"
// - "database.*:operation"
// где database/collection/operation — из безопасного набора символов.
func validatePermission(perm string) error {
if perm == "" {
return fmt.Errorf("permission cannot be empty")
}
if !permissionRegex.MatchString(perm) {
return fmt.Errorf("invalid permission format: %q", perm)
}
return nil
}
// dedupeRoles убирает дубликаты из списка ролей, сохраняя порядок.
func dedupeRoles(roles []string) []string {
if len(roles) == 0 {
return []string{}
}
seen := make(map[string]struct{}, len(roles))
out := make([]string, 0, len(roles))
for _, r := range roles {
if r == "" {
continue
}
if _, ok := seen[r]; ok {
continue
}
seen[r] = struct{}{}
out = append(out, r)
}
return out
}
// =============================================================================
// БЛОКИРОВКА АККАУНТА
// =============================================================================
// isAccountLocked проверяет, заблокирован ли аккаунт.
// Автоматически снимает блокировку, если срок истёк.
// ВАЖНО: вызывается под m.mu (read-modify-write), поэтому мутация
// user здесь безопасна.
func (m *ACLManager) isAccountLocked(user *User) bool {
if user.LockedUntil == 0 {
return false
}
if time.Now().Unix() < user.LockedUntil {
return true
}
user.LockedUntil = 0
user.FailedAttempts = 0
return false
}
// =============================================================================
// ПОЛЬЗОВАТЕЛИ
// =============================================================================
// CreateUser создаёт нового пользователя
func (m *ACLManager) CreateUser(username, password string, roles []string) error {
if err := validateUsername(username); err != nil {
return err
}
if err := validatePassword(password); err != nil {
return err
}
hash, err := hashPassword(password)
if err != nil {
return fmt.Errorf("failed to hash password: %w", err)
}
m.mu.Lock()
defer m.mu.Unlock()
if _, exists := m.users.Load(username); exists {
return fmt.Errorf("user %s already exists", username)
}
user := &User{
ID: uuid.New().String(),
Username: username,
PasswordHash: hash,
Roles: dedupeRoles(roles),
CreatedAt: time.Now().UnixMilli(),
Active: true,
}
m.users.Store(username, user)
return nil
}
// Authenticate аутентифицирует пользователя и создаёт сессию.
//
// ИСПРАВЛЕНО: read-modify-write для user.FailedAttempts / LockedUntil /
// LastLogin выполняется под m.mu — иначе конкурентные попытки входа
// могли терять инкременты счётчика неудач.
func (m *ACLManager) Authenticate(username, password string) (string, error) {
if err := validateUsername(username); err != nil {
return "", fmt.Errorf("invalid credentials")
}
m.mu.Lock()
defer m.mu.Unlock()
val, ok := m.users.Load(username)
if !ok {
// Constant-time фиктивная проверка для защиты от user enumeration
_ = verifyPassword(password, "pbkdf2$100000$AAAAAAAAAAAAAAAAAAAAAA==$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")
return "", fmt.Errorf("invalid credentials")
}
// Копируем пользователя, чтобы не мутировать объект, который
// могут читать другие горутины.
src := val.(*User)
user := *src
user.Roles = append([]string(nil), src.Roles...)
if !user.Active {
return "", fmt.Errorf("user is disabled")
}
if m.isAccountLocked(&user) {
return "", fmt.Errorf("account is temporarily locked")
}
var valid bool
if user.PasswordHash != "" {
valid = verifyPassword(password, user.PasswordHash)
} else if user.LegacyPassword != "" {
valid = verifyLegacyPassword(password, user.LegacyPassword)
if valid {
// Миграция на новый формат
if newHash, err := hashPassword(password); err == nil {
user.PasswordHash = newHash
user.LegacyPassword = ""
}
}
}
if !valid {
user.FailedAttempts++
if user.FailedAttempts >= maxFailedAttempts {
user.LockedUntil = time.Now().Add(lockoutDuration).Unix()
user.FailedAttempts = 0
m.logWarn(fmt.Sprintf("acl: account %s locked for %s (too many failed logins)",
username, lockoutDuration))
}
m.users.Store(username, &user)
return "", fmt.Errorf("invalid credentials")
}
user.LastLogin = time.Now().UnixMilli()
user.FailedAttempts = 0
user.LockedUntil = 0
m.users.Store(username, &user)
// Ограничиваем количество активных сессий на пользователя.
m.enforceSessionLimitLocked(username)
sessionID := uuid.New().String()
now := time.Now()
session := &Session{
ID: sessionID,
Username: username,
Roles: append([]string(nil), user.Roles...),
CreatedAt: now.Unix(),
ExpiresAt: now.Add(sessionTTL).Unix(),
}
m.sessions.Store(sessionID, session)
return sessionID, nil
}
// enforceSessionLimitLocked удаляет старейшие сессии пользователя,
// если их количество достигло maxSessionsPerUser.
// Вызывается под m.mu.
func (m *ACLManager) enforceSessionLimitLocked(username string) {
type sess struct {
id string
createdAt int64
}
var userSessions []sess
m.sessions.Range(func(key, value interface{}) bool {
s := value.(*Session)
if s.Username == username {
userSessions = append(userSessions, sess{id: s.ID, createdAt: s.CreatedAt})
}
return true
})
if len(userSessions) < maxSessionsPerUser {
return
}
// Сортируем по createdAt возрастанию и удаляем самые старые,
// чтобы после добавления новой сессии лимит не был превышен.
sort.Slice(userSessions, func(i, j int) bool {
return userSessions[i].createdAt < userSessions[j].createdAt
})
toDelete := len(userSessions) - maxSessionsPerUser + 1
for i := 0; i < toDelete; i++ {
m.sessions.Delete(userSessions[i].id)
}
}
// Logout завершает сессию
func (m *ACLManager) Logout(sessionID string) {
m.sessions.Delete(sessionID)
}
// CheckSession проверяет, активна ли сессия (с учётом expiry)
func (m *ACLManager) CheckSession(sessionID string) bool {
return m.getSession(sessionID) != nil
}
// CheckSessionForIP проверяет сессию и совпадение IP клиента.
// Если у сессии не задан CreatedByIP — проверка IP пропускается.
func (m *ACLManager) CheckSessionForIP(sessionID, clientIP string) bool {
s := m.getSession(sessionID)
if s == nil {
return false
}
if s.CreatedByIP != "" && s.CreatedByIP != clientIP {
return false
}
return true
}
// getSession возвращает валидную сессию или nil.
func (m *ACLManager) getSession(sessionID string) *Session {
val, ok := m.sessions.Load(sessionID)
if !ok {
return nil
}
session := val.(*Session)
if time.Now().Unix() > session.ExpiresAt {
m.sessions.Delete(sessionID)
return nil
}
return session
}
// GetUsername возвращает имя пользователя по ID сессии
func (m *ACLManager) GetUsername(sessionID string) string {
session := m.getSession(sessionID)
if session == nil {
return ""
}
return session.Username
}
// GetUserRoles возвращает роли пользователя по ID сессии
func (m *ACLManager) GetUserRoles(sessionID string) []string {
session := m.getSession(sessionID)
if session == nil {
return []string{}
}
return append([]string(nil), session.Roles...)
}
// GetUserRolesByName возвращает роли пользователя по имени.
func (m *ACLManager) GetUserRolesByName(username string) []string {
val, ok := m.users.Load(username)
if !ok {
return []string{}
}
user := val.(*User)
return append([]string(nil), user.Roles...)
}
// GetUserInfo возвращает информацию о пользователе (без пароля)
func (m *ACLManager) GetUserInfo(username string) (*User, error) {
val, ok := m.users.Load(username)
if !ok {
return nil, fmt.Errorf("user not found")
}
user := val.(*User)
return &User{
ID: user.ID,
Username: user.Username,
Roles: append([]string(nil), user.Roles...),
CreatedAt: user.CreatedAt,
LastLogin: user.LastLogin,
Active: user.Active,
}, nil
}
// DisableUser отключает пользователя.
// ИСПРАВЛЕНО: инвалидирует все сессии пользователя.
func (m *ACLManager) DisableUser(username string) error {
if username == "admin" {
return fmt.Errorf("cannot disable built-in admin")
}
m.mu.Lock()
defer m.mu.Unlock()
val, ok := m.users.Load(username)
if !ok {
return fmt.Errorf("user not found")
}
src := val.(*User)
user := *src
user.Active = false
m.users.Store(username, &user)
// Инвалидируем все сессии
m.dropUserSessionsLocked(username)
return nil
}
// EnableUser включает пользователя
func (m *ACLManager) EnableUser(username string) error {
m.mu.Lock()
defer m.mu.Unlock()
val, ok := m.users.Load(username)
if !ok {
return fmt.Errorf("user not found")
}
src := val.(*User)
user := *src
user.Active = true
m.users.Store(username, &user)
return nil
}
// DeleteUser удаляет пользователя.
// ИСПРАВЛЕНО: инвалидирует все сессии пользователя.
func (m *ACLManager) DeleteUser(username string) error {
if username == "admin" {
return fmt.Errorf("cannot delete built-in admin")
}
m.mu.Lock()
defer m.mu.Unlock()
if _, exists := m.users.LoadAndDelete(username); !exists {
return fmt.Errorf("user not found")
}
m.dropUserSessionsLocked(username)
return nil
}
// ChangePassword изменяет пароль пользователя.
// ИСПРАВЛЕНО: инвалидирует все сессии пользователя (кроме текущей,
// если нужно, — но проще сбросить все, чтобы гарантировать
// безопасность при смене пароля администратором).
func (m *ACLManager) ChangePassword(username, newPassword string) error {
if err := validatePassword(newPassword); err != nil {
return err
}
hash, err := hashPassword(newPassword)
if err != nil {
return err
}
m.mu.Lock()
defer m.mu.Unlock()
val, ok := m.users.Load(username)
if !ok {
return fmt.Errorf("user not found")
}
src := val.(*User)
user := *src
user.PasswordHash = hash
user.LegacyPassword = ""
m.users.Store(username, &user)
m.dropUserSessionsLocked(username)
return nil
}
// dropUserSessionsLocked удаляет все сессии пользователя.
// Вызывается под m.mu.
func (m *ACLManager) dropUserSessionsLocked(username string) {
var toDelete []string
m.sessions.Range(func(key, value interface{}) bool {
s := value.(*Session)
if s.Username == username {
toDelete = append(toDelete, s.ID)
}
return true
})
for _, id := range toDelete {
m.sessions.Delete(id)
}
}
// ListUsers возвращает отсортированный список всех пользователей
func (m *ACLManager) ListUsers() []string {
users := make([]string, 0)
m.users.Range(func(key, value interface{}) bool {
users = append(users, key.(string))
return true
})
sort.Strings(users)
return users
}
// =============================================================================
// РОЛИ
// =============================================================================
// CreateRole создаёт новую роль
func (m *ACLManager) CreateRole(name string) error {
if name == "" {
return fmt.Errorf("role name cannot be empty")
}
m.mu.Lock()
defer m.mu.Unlock()
if _, exists := m.roles.Load(name); exists {
return fmt.Errorf("role %s already exists", name)
}
role := &Role{Name: name, Permissions: []string{}}
m.roles.Store(name, role)
return nil
}
// DeleteRole удаляет роль.
// ИСПРАВЛЕНО: также удаляет роль у всех пользователей,
// у которых она была — иначе в сессиях/юзерах остаётся «висячая» роль.
func (m *ACLManager) DeleteRole(name string) error {
if name == "admin" {
return fmt.Errorf("cannot delete built-in role 'admin'")
}
m.mu.Lock()
defer m.mu.Unlock()
if _, exists := m.roles.LoadAndDelete(name); !exists {
return fmt.Errorf("role not found")
}
// Убираем роль у всех пользователей
m.users.Range(func(key, value interface{}) bool {
src := value.(*User)
hasRole := false
for _, r := range src.Roles {
if r == name {
hasRole = true
break
}
}
if !hasRole {
return true
}
user := *src
newRoles := make([]string, 0, len(user.Roles))
for _, r := range user.Roles {
if r != name {
newRoles = append(newRoles, r)
}
}
user.Roles = newRoles
m.users.Store(key.(string), &user)
return true
})
// И инвалидируем все сессии, у которых была эта роль.
var toDelete []string
m.sessions.Range(func(key, value interface{}) bool {
s := value.(*Session)
for _, r := range s.Roles {
if r == name {
toDelete = append(toDelete, s.ID)
break
}
}
return true
})
for _, id := range toDelete {
m.sessions.Delete(id)
}
return nil
}
// ListRoles возвращает отсортированный список всех ролей
func (m *ACLManager) ListRoles() []string {
roles := make([]string, 0)
m.roles.Range(func(key, value interface{}) bool {
roles = append(roles, key.(string))
return true
})
sort.Strings(roles)
return roles
}
// GetRolePermissions возвращает разрешения роли (копию)
func (m *ACLManager) GetRolePermissions(roleName string) ([]string, error) {
val, ok := m.roles.Load(roleName)
if !ok {
return nil, fmt.Errorf("role not found")
}
role := val.(*Role)
return append([]string(nil), role.Permissions...), nil
}
// GrantPermission выдаёт разрешение роли (с дедупликацией).
// ИСПРАВЛЕНО: валидирует формат разрешения и мутирует под m.mu.
func (m *ACLManager) GrantPermission(roleName, permission string) error {
if err := validatePermission(permission); err != nil {
return err
}
m.mu.Lock()
defer m.mu.Unlock()
val, ok := m.roles.Load(roleName)
if !ok {
return fmt.Errorf("role not found")
}
src := val.(*Role)
role := *src
role.Permissions = append([]string(nil), src.Permissions...)
for _, p := range role.Permissions {
if p == permission {
return nil // уже есть
}
}
role.Permissions = append(role.Permissions, permission)
m.roles.Store(roleName, &role)
return nil
}
// RevokePermission отзывает разрешение у роли.
func (m *ACLManager) RevokePermission(roleName, permission string) error {
m.mu.Lock()
defer m.mu.Unlock()
val, ok := m.roles.Load(roleName)
if !ok {
return fmt.Errorf("role not found")
}
src := val.(*Role)
role := *src
newPermissions := make([]string, 0, len(role.Permissions))
for _, p := range src.Permissions {
if p != permission {
newPermissions = append(newPermissions, p)
}
}
role.Permissions = newPermissions
m.roles.Store(roleName, &role)
return nil
}
// =============================================================================
// РОЛИ ПОЛЬЗОВАТЕЛЯ
// =============================================================================
// AddUserRole добавляет роль пользователю.
// ИСПРАВЛЕНО: мутирует под m.mu; инвалидирует сессии пользователя,
// чтобы новоприобретённая роль вступила в силу при следующем входе.
func (m *ACLManager) AddUserRole(username, roleName string) error {
m.mu.Lock()
defer m.mu.Unlock()
val, ok := m.users.Load(username)
if !ok {
return fmt.Errorf("user not found")
}
src := val.(*User)
for _, r := range src.Roles {
if r == roleName {
return fmt.Errorf("user already has role %s", roleName)
}
}
user := *src
user.Roles = append(append([]string(nil), src.Roles...), roleName)
m.users.Store(username, &user)
// Инвалидируем сессии пользователя: после смены ролей старые
// сессии не должны сохранять прежний набор прав.
m.dropUserSessionsLocked(username)
return nil
}
// RemoveUserRole удаляет роль у пользователя.
// ИСПРАВЛЕНО: мутирует под m.mu; инвалидирует сессии пользователя.
func (m *ACLManager) RemoveUserRole(username, roleName string) error {
m.mu.Lock()
defer m.mu.Unlock()
val, ok := m.users.Load(username)
if !ok {
return fmt.Errorf("user not found")
}
src := val.(*User)
newRoles := make([]string, 0, len(src.Roles))
for _, r := range src.Roles {
if r != roleName {
newRoles = append(newRoles, r)
}
}
user := *src
user.Roles = newRoles
m.users.Store(username, &user)
m.dropUserSessionsLocked(username)
return nil
}
// =============================================================================
// ПРОВЕРКА РАЗРЕШЕНИЙ
// =============================================================================
// CheckPermission проверяет разрешение для сессии.
//
// КРИТИЧЕСКОЕ ИСПРАВЛЕНИЕ: раньше при operation == "admin" любая роль
// с любым разрешением получала доступ. Теперь:
// 1. Сначала проверяется валидность сессии (expiry).
// 2. Для admin-операций требуется разрешение "*:*" или явное "...:admin".
// 3. Для обычных операций проверяется соответствие ресурса и операции.
func (m *ACLManager) CheckPermission(sessionID, database, collection, operation string) bool {
session := m.getSession(sessionID)
if session == nil {
return false
}
for _, roleName := range session.Roles {
roleVal, ok := m.roles.Load(roleName)
if !ok {
continue
}
role := roleVal.(*Role)
for _, perm := range role.Permissions {
if matchPermission(perm, database, collection, operation) {
return true
}
}
}
return false
}
// matchPermission проверяет соответствие одного разрешения запросу.
//
// Формат разрешения: "database.collection:operation"
// - "*:*" — полный доступ ко всему (включая admin)
// - "db.*:read" — read всех коллекций в db
// - "db.coll:admin" — admin-доступ к конкретной коллекции
// - "db.coll:*" — все операции на db.coll
//
// ИСПРАВЛЕНО: убран мёртвый код с проверкой resource == "*:*".
// Раньше splitPermission("*:*") давал resource="*", op="*", и первая
// ветка "if resource == \"*:*\" && op == \"*\"" никогда не срабатывала.
// Теперь wildcard-разрешение обрабатывается явно, до split.
func matchPermission(perm, database, collection, operation string) bool {
// Полный доступ — единственная форма, где resource содержит ":".
if perm == "*:*" {
return true
}
parts := splitPermission(perm)
if len(parts) != 2 {
return false
}
resource := parts[0]
op := parts[1]
// Явный admin-доступ: требуется op == "admin" (или "*").
if operation == "admin" {
if op != "admin" && op != "*" {
return false
}
resourceParts := splitResource(resource)
if len(resourceParts) != 2 {
return false
}
dbMatch := resourceParts[0] == "*" || resourceParts[0] == database
collMatch := resourceParts[1] == "*" || resourceParts[1] == collection
return dbMatch && collMatch
}
// Обычные операции: op == "*" или совпадает с запрошенной.
if op != "*" && op != operation {
return false
}
resourceParts := splitResource(resource)
if len(resourceParts) != 2 {
return false
}
dbPattern := resourceParts[0]
collPattern := resourceParts[1]
if dbPattern != "*" && dbPattern != database {
return false
}
if collPattern != "*" && collPattern != collection {
return false
}
return true
}
// =============================================================================
// ФОНОВАЯ ОЧИСТКА СЕССИЙ
// =============================================================================
// sessionCleanupLoop периодически удаляет истёкшие сессии.
func (m *ACLManager) sessionCleanupLoop() {
defer m.wg.Done()
ticker := time.NewTicker(sessionCleanupEvery)
defer ticker.Stop()
for {
select {
case <-ticker.C:
now := time.Now().Unix()
m.sessions.Range(func(key, value interface{}) bool {
session := value.(*Session)
if now > session.ExpiresAt {
m.sessions.Delete(key)
}
return true
})
case <-m.stopChan:
return
}
}
}
// Stop останавливает фоновые процессы ACLManager.
// Безопасен для многократного вызова.
func (m *ACLManager) Stop() {
m.stopOnce.Do(func() {
close(m.stopChan)
})
m.wg.Wait()
}
// =============================================================================
// ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ
// =============================================================================
// splitPermission разделяет строку разрешения на ресурс и операцию.
func splitPermission(perm string) []string {
for i := 0; i < len(perm); i++ {
if perm[i] == ':' {
return []string{perm[:i], perm[i+1:]}
}
}
return []string{perm, ""}
}
// splitResource разделяет ресурс на базу данных и коллекцию.
func splitResource(resource string) []string {
for i := 0; i < len(resource); i++ {
if resource[i] == '.' {
return []string{resource[:i], resource[i+1:]}
}
}
return []string{resource, "*"}
}