Files
futriix/internal/acl/manger.go
T

833 lines
26 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
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
// Параметры блокировки аккаунта
maxFailedAttempts = 5
lockoutDuration = 15 * time.Minute
)
// usernameRegex разрешает только безопасные символы в имени пользователя.
var usernameRegex = regexp.MustCompile(`^[a-zA-Z0-9_.@-]+$`)
// =============================================================================
// ТИПЫ
// =============================================================================
// PermissionType определяет тип разрешения
type PermissionType string
const (
PermRead PermissionType = "read"
PermWrite PermissionType = "write"
PermDelete PermissionType = "delete"
PermAdmin PermissionType = "admin"
)
// 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 представляет активную сессию пользователя
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"`
}
// Role представляет роль с набором разрешений
type Role struct {
Name string `msgpack:"name"`
Permissions []string `msgpack:"permissions"`
}
// ACLManager управляет доступом к БД
type ACLManager struct {
users sync.Map
roles sync.Map
sessions sync.Map
mu sync.RWMutex
stopChan chan struct{}
wg sync.WaitGroup
stopOnce sync.Once
}
// =============================================================================
// КОНСТРУКТОР
// =============================================================================
// NewACLManager создаёт новый менеджер ACL.
//
// Создаёт роль "admin" с полными правами и пользователя "admin"
// со случайным паролем, который выводится в stderr. Пользователь guest
// не создаётся — если нужен гостевой доступ, создайте его явно.
func NewACLManager() *ACLManager {
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 {
randomBytes = []byte(uuid.New().String())
}
adminPassword := base64.RawURLEncoding.EncodeToString(randomBytes)
adminHash, err := hashPassword(adminPassword)
if err != nil {
// Fallback: сохраняем пароль в legacy-формате, чтобы не сломать старт
adminHash = adminPassword
}
adminUser := &User{
ID: uuid.New().String(),
Username: "admin",
PasswordHash: adminHash,
Roles: []string{"admin"},
CreatedAt: time.Now().UnixMilli(),
Active: true,
}
m.users.Store("admin", adminUser)
// Выводим пароль в stderr (не в лог), чтобы администратор его увидел
fmt.Fprintf(os.Stderr, "\n"+
"======================================================================\n"+
" ACL: создан пользователь 'admin' со случайным паролем.\n"+
" Пароль: %s\n"+
" Смените пароль после первого входа командой 'acl change-password'!\n"+
"======================================================================\n\n",
adminPassword)
// Запускаем фоновую очистку сессий
m.wg.Add(1)
go m.sessionCleanupLoop()
return m
}
// =============================================================================
// ХЕШИРОВАНИЕ ПАРОЛЕЙ (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
}
// =============================================================================
// БЛОКИРОВКА АККАУНТА
// =============================================================================
// isAccountLocked проверяет, заблокирован ли аккаунт.
// Автоматически снимает блокировку, если срок истёк.
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
}
if _, exists := m.users.Load(username); exists {
return fmt.Errorf("user %s already exists", username)
}
hash, err := hashPassword(password)
if err != nil {
return fmt.Errorf("failed to hash password: %w", err)
}
user := &User{
ID: uuid.New().String(),
Username: username,
PasswordHash: hash,
Roles: roles,
CreatedAt: time.Now().UnixMilli(),
Active: true,
}
m.users.Store(username, user)
return nil
}
// Authenticate аутентифицирует пользователя и создаёт сессию.
func (m *ACLManager) Authenticate(username, password string) (string, error) {
if err := validateUsername(username); err != nil {
return "", fmt.Errorf("invalid credentials")
}
val, ok := m.users.Load(username)
if !ok {
// Constant-time фиктивная проверка для защиты от user enumeration
_ = verifyPassword(password, "pbkdf2$100000$AAAAAAAAAAAAAAAAAAAAAA==$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")
return "", fmt.Errorf("invalid credentials")
}
user := val.(*User)
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.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)
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
}
// 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
}
// 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")
}
val, ok := m.users.Load(username)
if !ok {
return fmt.Errorf("user not found")
}
user := val.(*User)
user.Active = false
m.users.Store(username, user)
return nil
}
// EnableUser включает пользователя
func (m *ACLManager) EnableUser(username string) error {
val, ok := m.users.Load(username)
if !ok {
return fmt.Errorf("user not found")
}
user := val.(*User)
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")
}
if _, exists := m.users.LoadAndDelete(username); !exists {
return fmt.Errorf("user not found")
}
return nil
}
// ChangePassword изменяет пароль пользователя
func (m *ACLManager) ChangePassword(username, newPassword string) error {
if err := validatePassword(newPassword); err != nil {
return err
}
val, ok := m.users.Load(username)
if !ok {
return fmt.Errorf("user not found")
}
user := val.(*User)
hash, err := hashPassword(newPassword)
if err != nil {
return err
}
user.PasswordHash = hash
user.LegacyPassword = ""
m.users.Store(username, user)
return nil
}
// 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")
}
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'")
}
if _, exists := m.roles.LoadAndDelete(name); !exists {
return fmt.Errorf("role not found")
}
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 выдаёт разрешение роли (с дедупликацией)
func (m *ACLManager) GrantPermission(roleName, permission string) error {
val, ok := m.roles.Load(roleName)
if !ok {
return fmt.Errorf("role not found")
}
role := val.(*Role)
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 {
val, ok := m.roles.Load(roleName)
if !ok {
return fmt.Errorf("role not found")
}
role := val.(*Role)
newPermissions := make([]string, 0, len(role.Permissions))
for _, p := range role.Permissions {
if p != permission {
newPermissions = append(newPermissions, p)
}
}
role.Permissions = newPermissions
m.roles.Store(roleName, role)
return nil
}
// =============================================================================
// РОЛИ ПОЛЬЗОВАТЕЛЯ
// =============================================================================
// AddUserRole добавляет роль пользователю
func (m *ACLManager) AddUserRole(username, roleName string) error {
val, ok := m.users.Load(username)
if !ok {
return fmt.Errorf("user not found")
}
user := val.(*User)
for _, r := range user.Roles {
if r == roleName {
return fmt.Errorf("user already has role %s", roleName)
}
}
user.Roles = append(user.Roles, roleName)
m.users.Store(username, user)
return nil
}
// RemoveUserRole удаляет роль у пользователя
func (m *ACLManager) RemoveUserRole(username, roleName string) error {
val, ok := m.users.Load(username)
if !ok {
return fmt.Errorf("user not found")
}
user := val.(*User)
newRoles := make([]string, 0, len(user.Roles))
for _, r := range user.Roles {
if r != roleName {
newRoles = append(newRoles, r)
}
}
user.Roles = newRoles
m.users.Store(username, user)
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
func matchPermission(perm, database, collection, operation string) bool {
parts := splitPermission(perm)
if len(parts) != 2 {
return false
}
resource := parts[0]
op := parts[1]
// Полный доступ
if resource == "*:*" && op == "*" {
return true
}
// Явный admin-доступ
if op == "admin" {
if resource == "*:*" {
return true
}
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, "*"}
}