Files
futriix/internal/acl/manger.go
T

1200 lines
41 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* 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, "*"}
}