Netpulse_SasS/server/internal/store/users.go
byrsapty 96692ec9df Вхід за логіном, групи хостів і права доступу
Логін:
- core.users.username замість пошти як ідентифікатор: у мережевій
  інсталяції половина акаунтів технічні (noc, monitoring, oncall) і
  скриньки не мають узагалі. Пошта лишилась необов'язковим полем
- наявним користувачам логін виведено з пошти, збіги розведено
  суфіксом: мовчки злити admin@a.com і admin@b.com в один логін —
  це втрата акаунта, а не міграція
- сервер шукає за логіном і за поштою, тому звичка вводити email
  нікого не відхиляє

Групи (модель Zabbix):
- групи хостів і групи доступу; права read/write/deny на групу хостів
- роль каже, що вільно робити; група — над якими хостами. Інженер над
  філією та інженер над усією мережею мають однакову роль і різний
  доступ
- хто не входить у жодну групу, групами не обмежений — свідомо не
  по-заббіксівськи: там кожна нова інсталяція починається з питання
  «чому порожньо»
- заборона перемагає дозвіл, інакше її обійти додаванням у сусідню групу
- фільтр накладається в самому запиті, а не після вибірки

Хости: додавання, редагування, м'яке видалення, прив'язка до груп,
фільтр за групою в таблиці.

Мапа: створення мапи з транслітерацією slug, інспектор вузла — підпис,
значок, розмір, ширина, колір рамки, закріплення.

Виправлено за скаргами:
- перемикач вилазив на 14px за трек: у ручки не заданий left, а
  статичну позицію зсуває типове text-align: center у <button>
- сторінка правил не оновлювала «активних»: після повернення правила
  алерти піднімаються наступним тіком движка, тобто ПІСЛЯ нашого
  перечитування — бракувало підписки на живі події
- канал із секретом показувався як «секрету немає»

Живий прогін: eng у групі з read на «Доступ» бачить 2 хости замість 3,
обидва без запису, алертів 1 замість 3, редагування чужого хоста 403.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 02:00:04 +03:00

558 lines
20 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.

package store
import (
"context"
"crypto/sha256"
"errors"
"fmt"
"net"
"time"
"github.com/jackc/pgx/v5"
"github.com/netpulse/netpulse/server/internal/auth"
)
// RefreshTTL — скільки живе сесія без повторного входу.
//
// 30 днів — компроміс між «щодня вводити пароль» і «звільнений
// співробітник місяць має доступ». Друге лікується відкликанням сесій
// при видаленні членства, а не коротшим строком.
const RefreshTTL = 30 * 24 * time.Hour
var (
ErrBadCredentials = errors.New("невірний логін або пароль")
ErrNoMembership = errors.New("користувач не належить жодному тенанту")
ErrSessionInvalid = errors.New("сесія недійсна")
)
type User struct {
ID string `json:"id"`
Username string `json:"username"`
Email string `json:"email,omitempty"`
FullName string `json:"full_name,omitempty"`
Locale string `json:"locale"`
Timezone string `json:"timezone"`
MFA bool `json:"mfa_enabled"`
}
// TenantMembership — до якого тенанта і з якою роллю належить користувач.
type TenantMembership struct {
TenantID string `json:"tenant_id"`
TenantName string `json:"tenant_name"`
TenantSlug string `json:"tenant_slug"`
RoleKey string `json:"role_key"`
RoleName string `json:"role_name"`
}
// TeamMember — рядок сторінки «Команда».
type TeamMember struct {
UserID string `json:"user_id"`
Username string `json:"username"`
Email string `json:"email,omitempty"`
FullName string `json:"full_name,omitempty"`
RoleID string `json:"role_id"`
RoleKey string `json:"role_key"`
RoleName string `json:"role_name"`
LastLogin *time.Time `json:"last_login_at,omitempty"`
AcceptedAt *time.Time `json:"accepted_at,omitempty"`
ScopeGroups []string `json:"scope_group_ids,omitempty"`
}
type Role struct {
ID string `json:"id"`
Key string `json:"key"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
IsSystem bool `json:"is_system"`
Permissions []string `json:"permissions"`
}
// ---------------------------------------------------------------------
// Вхід
// ---------------------------------------------------------------------
// AuthenticateUser перевіряє логін і пароль.
//
// Ідентифікатором є username, а не пошта: у мережевій інсталяції
// половина облікових записів технічні (noc, monitoring, oncall) і
// скриньки не мають узагалі. Пошта лишається прийнятною — люди звикли
// вводити її, і відмовляти їм у вході через це було б причепливістю, —
// але вона більше не єдиний шлях і не обов'язкове поле.
func (s *Store) AuthenticateUser(ctx context.Context, login, password string, ip net.IP, userAgent string) (*User, []TenantMembership, error) {
var (
u User
hash *string
email *string
)
err := s.pool.QueryRow(ctx, `
SELECT id::text, username::text, email::text, COALESCE(full_name,''),
locale, timezone, mfa_enabled, password_hash
FROM core.users
WHERE (username = $1 OR email = $1) AND deleted_at IS NULL
-- Збіг за логіном виграє: пошта одного може випадково збігтися
-- з логіном іншого, і тоді пріоритет має мати той, кому цей
-- рядок належить як ідентифікатор.
ORDER BY (username = $1) DESC
LIMIT 1
`, login).Scan(&u.ID, &u.Username, &email, &u.FullName, &u.Locale, &u.Timezone, &u.MFA, &hash)
if errors.Is(err, pgx.ErrNoRows) {
// Спалюємо стільки ж часу, скільки справжня перевірка: інакше
// швидка відповідь видає, що такого логіна немає, і перебір
// перетворюється на збір списку зареєстрованих акаунтів.
auth.DummyVerify(password)
s.recordLogin(ctx, login, ip, userAgent, false, "no_user", "")
return nil, nil, ErrBadCredentials
}
if err != nil {
return nil, nil, err
}
if email != nil {
u.Email = *email
}
if hash == nil || *hash == "" {
// Користувач лише через SSO — локального пароля немає.
auth.DummyVerify(password)
s.recordLogin(ctx, login, ip, userAgent, false, "no_password", u.ID)
return nil, nil, ErrBadCredentials
}
if err := auth.VerifyPassword(*hash, password); err != nil {
s.recordLogin(ctx, login, ip, userAgent, false, "bad_password", u.ID)
return nil, nil, ErrBadCredentials
}
memberships, err := s.userMemberships(ctx, u.ID)
if err != nil {
return nil, nil, err
}
if len(memberships) == 0 {
s.recordLogin(ctx, login, ip, userAgent, false, "no_membership", u.ID)
return nil, nil, ErrNoMembership
}
s.recordLogin(ctx, login, ip, userAgent, true, "", u.ID)
if _, err := s.pool.Exec(ctx,
`UPDATE core.users SET last_login_at = now() WHERE id = $1`, u.ID); err != nil {
return nil, nil, err
}
return &u, memberships, nil
}
func (s *Store) userMemberships(ctx context.Context, userID string) ([]TenantMembership, error) {
rows, err := s.pool.Query(ctx, `
SELECT t.id::text, t.name, t.slug::text, r.key::text, r.name
FROM core.memberships m
JOIN core.tenants t ON t.id = m.tenant_id
JOIN core.roles r ON r.id = m.role_id
WHERE m.user_id = $1 AND t.deleted_at IS NULL
ORDER BY t.name
`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []TenantMembership
for rows.Next() {
var m TenantMembership
if err := rows.Scan(&m.TenantID, &m.TenantName, &m.TenantSlug,
&m.RoleKey, &m.RoleName); err != nil {
return nil, err
}
out = append(out, m)
}
return out, rows.Err()
}
// recordLogin пише спробу входу. Помилки навмисно глушаться: збій
// журналювання не має заважати людині увійти.
func (s *Store) recordLogin(ctx context.Context, email string, ip net.IP, ua string, ok bool, reason, userID string) {
var ipArg any
if ip != nil {
ipArg = ip.String()
}
_, _ = s.pool.Exec(ctx, `
INSERT INTO core.login_attempts (email, ip, user_agent, success, reason, user_id)
VALUES ($1, $2::inet, $3, $4, NULLIF($5,''), $6)
`, email, ipArg, nullString(ua), ok, reason, nullUUID(userID))
}
// FailedLoginsSince рахує невдалі спроби — основа для гальмування
// перебору на рівні застосунку.
func (s *Store) FailedLoginsSince(ctx context.Context, email string, since time.Duration) (int, error) {
var n int
err := s.pool.QueryRow(ctx, `
SELECT count(*) FROM core.login_attempts
WHERE email = $1 AND NOT success AND ts > now() - $2::interval
`, email, since.String()).Scan(&n)
return n, err
}
// ---------------------------------------------------------------------
// Сесії
// ---------------------------------------------------------------------
type Session struct {
ID string
UserID string
TenantID string
RefreshToken string
ExpiresAt time.Time
}
// CreateSession видає refresh-токен. У БД лягає лише його sha256.
func (s *Store) CreateSession(ctx context.Context, userID, tenantID string, ip net.IP, ua string) (*Session, error) {
token, err := auth.NewToken("npr_")
if err != nil {
return nil, err
}
sum := sha256.Sum256([]byte(token))
expires := time.Now().Add(RefreshTTL)
var ipArg any
if ip != nil {
ipArg = ip.String()
}
sess := &Session{UserID: userID, TenantID: tenantID, RefreshToken: token, ExpiresAt: expires}
err = s.pool.QueryRow(ctx, `
INSERT INTO core.sessions (user_id, tenant_id, token_hash, user_agent, ip, expires_at)
VALUES ($1, $2, $3, $4, $5::inet, $6)
RETURNING id::text
`, userID, tenantID, sum[:], nullString(ua), ipArg, expires).Scan(&sess.ID)
if err != nil {
return nil, err
}
return sess, nil
}
// RotateSession обмінює refresh-токен на новий.
//
// Ротація при кожному обміні, а не подовження строку: викрадений
// refresh-токен спрацює щонайбільше один раз, і законний власник
// одразу отримає відмову — це видимий сигнал компрометації.
func (s *Store) RotateSession(ctx context.Context, refreshToken string, ip net.IP, ua string) (*Session, *User, error) {
sum := sha256.Sum256([]byte(refreshToken))
var (
oldID, userID, tenantID string
expires time.Time
revoked *time.Time
)
err := s.pool.QueryRow(ctx, `
SELECT id::text, user_id::text, COALESCE(tenant_id::text,''), expires_at, revoked_at
FROM core.sessions WHERE token_hash = $1
`, sum[:]).Scan(&oldID, &userID, &tenantID, &expires, &revoked)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil, ErrSessionInvalid
}
if err != nil {
return nil, nil, err
}
if revoked != nil || time.Now().After(expires) {
return nil, nil, ErrSessionInvalid
}
var u User
if err := s.pool.QueryRow(ctx, `
SELECT id::text, email::text, COALESCE(full_name,''), locale, timezone, mfa_enabled
FROM core.users WHERE id = $1 AND deleted_at IS NULL
`, userID).Scan(&u.ID, &u.Email, &u.FullName, &u.Locale, &u.Timezone, &u.MFA); err != nil {
return nil, nil, ErrSessionInvalid
}
// Членство могли відкликати вже після входу — перевіряємо щоразу,
// інакше звільнений співробітник продовжував би подовжувати сесію.
var stillMember bool
if err := s.pool.QueryRow(ctx, `
SELECT EXISTS (SELECT 1 FROM core.memberships WHERE user_id = $1 AND tenant_id = $2)
`, userID, tenantID).Scan(&stillMember); err != nil {
return nil, nil, err
}
if !stillMember {
return nil, nil, ErrSessionInvalid
}
if _, err := s.pool.Exec(ctx,
`UPDATE core.sessions SET revoked_at = now() WHERE id = $1`, oldID); err != nil {
return nil, nil, err
}
sess, err := s.CreateSession(ctx, userID, tenantID, ip, ua)
if err != nil {
return nil, nil, err
}
return sess, &u, nil
}
func (s *Store) RevokeSession(ctx context.Context, refreshToken string) error {
sum := sha256.Sum256([]byte(refreshToken))
_, err := s.pool.Exec(ctx, `
UPDATE core.sessions SET revoked_at = now()
WHERE token_hash = $1 AND revoked_at IS NULL
`, sum[:])
return err
}
// RevokeAllSessions — вихід з усіх пристроїв; те саме робиться при зміні
// пароля й при видаленні членства.
func (s *Store) RevokeAllSessions(ctx context.Context, userID string) error {
_, err := s.pool.Exec(ctx, `
UPDATE core.sessions SET revoked_at = now()
WHERE user_id = $1 AND revoked_at IS NULL
`, userID)
return err
}
// ---------------------------------------------------------------------
// Права
// ---------------------------------------------------------------------
// UserPermissions збирає ефективні права людини в тенанті.
//
// Джерело — membership → role → role_permissions. Кешувати тут нічого не
// треба: це один індексний join, а кеш прав — класичне джерело «звільнив,
// а він ще годину має доступ».
func (s *Store) UserPermissions(ctx context.Context, userID, tenantID string) ([]string, error) {
rows, err := s.pool.Query(ctx, `
SELECT rp.permission_key
FROM core.memberships m
JOIN core.role_permissions rp ON rp.role_id = m.role_id
WHERE m.user_id = $1 AND m.tenant_id = $2
`, userID, tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []string
for rows.Next() {
var p string
if err := rows.Scan(&p); err != nil {
return nil, err
}
out = append(out, p)
}
return out, rows.Err()
}
// ScopeGroups — обмеження видимості пристроїв для користувача.
// nil означає «усі пристрої тенанта».
func (s *Store) ScopeGroups(ctx context.Context, userID, tenantID string) ([]string, error) {
var groups []string
err := s.pool.QueryRow(ctx, `
SELECT scope_group_ids::text[]
FROM core.memberships WHERE user_id = $1 AND tenant_id = $2
`, userID, tenantID).Scan(&groups)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNoMembership
}
return groups, err
}
// ---------------------------------------------------------------------
// Команда
// ---------------------------------------------------------------------
func (s *Store) ListTeam(ctx context.Context, tenantID string) ([]TeamMember, error) {
var out []TeamMember
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
rows, err := tx.Query(ctx, `
SELECT u.id::text, u.username::text, COALESCE(u.email::text,''),
COALESCE(u.full_name,''),
r.id::text, r.key::text, r.name,
u.last_login_at, m.accepted_at, m.scope_group_ids::text[]
FROM core.memberships m
JOIN core.users u ON u.id = m.user_id
JOIN core.roles r ON r.id = m.role_id
WHERE m.tenant_id = $1 AND u.deleted_at IS NULL
ORDER BY u.username
`, tenantID)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var t TeamMember
if err := rows.Scan(&t.UserID, &t.Username, &t.Email, &t.FullName,
&t.RoleID, &t.RoleKey, &t.RoleName,
&t.LastLogin, &t.AcceptedAt, &t.ScopeGroups); err != nil {
return err
}
out = append(out, t)
}
return rows.Err()
})
return out, err
}
func (s *Store) ListRoles(ctx context.Context, tenantID string) ([]Role, error) {
var out []Role
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
rows, err := tx.Query(ctx, `
SELECT r.id::text, r.key::text, r.name, COALESCE(r.description,''), r.is_system,
COALESCE(array_agg(rp.permission_key ORDER BY rp.permission_key)
FILTER (WHERE rp.permission_key IS NOT NULL), '{}')
FROM core.roles r
LEFT JOIN core.role_permissions rp ON rp.role_id = r.id
WHERE r.tenant_id IS NULL OR r.tenant_id = $1
GROUP BY r.id
ORDER BY r.is_system DESC, r.name
`, tenantID)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var r Role
if err := rows.Scan(&r.ID, &r.Key, &r.Name, &r.Description,
&r.IsSystem, &r.Permissions); err != nil {
return err
}
out = append(out, r)
}
return rows.Err()
})
return out, err
}
// CreateUser заводить користувача й одразу дає йому членство.
//
// Використовується і CLI-початківцем (перший власник), і запрошеннями.
func (s *Store) CreateUser(ctx context.Context, tenantID, username, email, password, fullName, roleKey string) (*User, error) {
hash, err := auth.HashPassword(password)
if err != nil {
return nil, err
}
var u User
err = s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
// Користувач глобальний: одна людина може працювати в кількох
// тенантах (типово для MSP-провайдера). Тому конфлікт за логіном
// означає «той самий чоловік», а не помилку — але пароль при
// цьому НЕ перезаписується: інакше адмін одного тенанта міняв би
// пароль людини в чужому.
var mail *string
err := tx.QueryRow(ctx, `
INSERT INTO core.users (username, email, password_hash, full_name, email_verified_at)
VALUES ($1, NULLIF($2,'')::citext, $3, NULLIF($4,''),
CASE WHEN NULLIF($2,'') IS NULL THEN NULL ELSE now() END)
ON CONFLICT (username) DO UPDATE
SET full_name = COALESCE(NULLIF(EXCLUDED.full_name,''), core.users.full_name)
RETURNING id::text, username::text, email::text,
COALESCE(full_name,''), locale, timezone, mfa_enabled
`, username, email, hash, fullName).
Scan(&u.ID, &u.Username, &mail, &u.FullName, &u.Locale, &u.Timezone, &u.MFA)
if err != nil {
return fmt.Errorf("створення користувача: %w", err)
}
if mail != nil {
u.Email = *mail
}
var roleID string
if err := tx.QueryRow(ctx, `
SELECT id::text FROM core.roles
WHERE key = $1 AND (tenant_id IS NULL OR tenant_id = $2)
ORDER BY tenant_id NULLS LAST
LIMIT 1
`, roleKey, tenantID).Scan(&roleID); err != nil {
return fmt.Errorf("роль %q: %w", roleKey, err)
}
if _, err := tx.Exec(ctx, `
INSERT INTO core.memberships (tenant_id, user_id, role_id, accepted_at)
VALUES ($1, $2, $3, now())
ON CONFLICT (tenant_id, user_id) DO UPDATE SET role_id = EXCLUDED.role_id
`, tenantID, u.ID, roleID); err != nil {
return fmt.Errorf("членство: %w", err)
}
return nil
})
if err != nil {
return nil, mapPgError(err)
}
return &u, nil
}
// SetRole змінює роль учасника команди.
func (s *Store) SetRole(ctx context.Context, tenantID, userID, roleID string) error {
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
tag, err := tx.Exec(ctx, `
UPDATE core.memberships SET role_id = $3
WHERE tenant_id = $1 AND user_id = $2
`, tenantID, userID, roleID)
if err != nil {
return mapPgError(err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
})
}
// RemoveMember прибирає людину з тенанта.
//
// Сам користувач лишається: він може працювати в іншому тенанті, а його
// сліди в аудиті мають лишатися читабельними. Зате всі сесії саме в
// цьому тенанті відкликаються негайно.
func (s *Store) RemoveMember(ctx context.Context, tenantID, userID string) error {
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
tag, err := tx.Exec(ctx, `
DELETE FROM core.memberships WHERE tenant_id = $1 AND user_id = $2
`, tenantID, userID)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
_, err = tx.Exec(ctx, `
UPDATE core.sessions SET revoked_at = now()
WHERE user_id = $1 AND tenant_id = $2 AND revoked_at IS NULL
`, userID, tenantID)
return err
})
}
// ChangePassword міняє пароль і відкликає всі інші сесії.
func (s *Store) ChangePassword(ctx context.Context, userID, oldPassword, newPassword string) error {
var hash *string
if err := s.pool.QueryRow(ctx,
`SELECT password_hash FROM core.users WHERE id = $1 AND deleted_at IS NULL`,
userID).Scan(&hash); err != nil {
return ErrBadCredentials
}
if hash == nil || auth.VerifyPassword(*hash, oldPassword) != nil {
return ErrBadCredentials
}
newHash, err := auth.HashPassword(newPassword)
if err != nil {
return err
}
if _, err := s.pool.Exec(ctx,
`UPDATE core.users SET password_hash = $2, updated_at = now() WHERE id = $1`,
userID, newHash); err != nil {
return err
}
// Зміна пароля — типова реакція на «здається, мене зламали».
// Якщо після неї чужа сесія лишається живою, дія безглузда.
return s.RevokeAllSessions(ctx, userID)
}