Netpulse_SasS/server/internal/store/users.go
byrsapty 95a19b074d
Some checks failed
CI / agent (push) Waiting to run
CI / hygiene (push) Successful in 9s
CI / web (push) Successful in 1m11s
CI / server (push) Successful in 1m35s
CI / dbtest (push) Has been cancelled
Історія входів: 93 записи були в базі й ніде не показувались
core.login_attempts наповнювалась на кожній спробі входу й читалась
рівно в одному місці — для стримування підбору. Ні ендпойнта, ні
сторінки. На питання «хто заходив у мій моніторинг» продукт мав
відповідь у базі й не мав способу її показати.

ГОЛОВНЕ БУЛА НЕ ВЕРСТКА. У таблиці немає tenant_id — саме тому ці
події й не в аудиті. Показати «як є» означало б віддати одному
кабінету спроби входу чужих людей. Прив'язка непряма: спроба ->
користувач -> членство, трьома шляхами одночасно (user_id, username,
email) в одному JOIN LATERAL з tenant_id усередині. Саме JOIN, а не
LEFT JOIN: без збігу рядок ПРИБИРАЄТЬСЯ, а не лишається без імені.

На бойових даних це не теорія: у власника немає пошти, тож за email
не прив'язується ЖОДНА з 94 спроб. Шлях через username дає 93.

Невдала спроба з неіснуючим логіном не належить нікому: рядком не
показується, але рахується числом — сигнал «логіни перебирають»
лишається, чужа людина в чужий кабінет не потрапляє.

Ізоляцію перевірено проти справжньої бази (TestLoginAttemptsTenantIsolation).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 01:06:42 +03:00

820 lines
32 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.bg.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.bg.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.bg.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.bg.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.bg.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.bg.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-токен спрацює щонайбільше один раз, і законний власник
// одразу отримає відмову — це видимий сигнал компрометації.
// RotationGrace — скільки щойно відкликаний токен ще веде до наступника.
//
// Тридцяти секунд вистачає на подвійний обмін від того самого клієнта
// (дві вкладки, повторний запит після обриву, подвійний ефект React) і
// замало для реального повтору перехопленого токена — той приходить
// хвилинами й годинами пізніше.
const RotationGrace = 30 * time.Second
// maxRotationHops — запобіжник на випадок зіпсованого ланцюга.
//
// Без нього пошкоджені дані (цикл у replaced_by) зациклили б запит.
const maxRotationHops = 5
// RotateSession обмінює refresh-токен на новий.
//
// Пред'явлений токен, який уже обміняли щойно, не вважається помилкою:
// запит іде за ланцюгом replaced_by до живої сесії й обмінює її. Інакше
// два одночасні відновлення сесії з одного браузера гарантовано вбивали
// б одне одного — і саме це виглядало як «просить логін після кожного
// оновлення сторінки».
//
// Токен, обміняний давно, лишається помилкою: це вже не гонка, а повтор.
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
replacedBy *string
)
err := s.bg.QueryRow(ctx, `
SELECT id::text, user_id::text, COALESCE(tenant_id::text,''),
expires_at, revoked_at, replaced_by::text
FROM core.sessions WHERE token_hash = $1
`, sum[:]).Scan(&oldID, &userID, &tenantID, &expires, &revoked, &replacedBy)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil, ErrSessionInvalid
}
if err != nil {
return nil, nil, err
}
if time.Now().After(expires) {
return nil, nil, ErrSessionInvalid
}
// Токен уже обміняли — йдемо за ланцюгом, поки не знайдемо живу
// сесію. Кожен крок перевіряється на вікно: старий обмін означає
// повтор, а не гонку.
for hop := 0; revoked != nil; hop++ {
if hop >= maxRotationHops || replacedBy == nil {
return nil, nil, ErrSessionInvalid
}
if time.Since(*revoked) > RotationGrace {
return nil, nil, ErrSessionInvalid
}
err := s.bg.QueryRow(ctx, `
SELECT id::text, expires_at, revoked_at, replaced_by::text
FROM core.sessions WHERE id = $1
`, *replacedBy).Scan(&oldID, &expires, &revoked, &replacedBy)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil, ErrSessionInvalid
}
if err != nil {
return nil, nil, err
}
if time.Now().After(expires) {
return nil, nil, ErrSessionInvalid
}
}
// COALESCE обов'язковий: пошта стала необов'язковою разом із входом
// за логіном, і без нього Scan падав на кожному користувачі без
// адреси. Виглядало це так, ніби сесія недійсна — тобто такі люди
// могли увійти, але після перезавантаження сторінки летіли на
// форму входу знову й знову.
var u User
if err := s.bg.QueryRow(ctx, `
SELECT id::text, COALESCE(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.bg.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
}
sess, err := s.CreateSession(ctx, userID, tenantID, ip, ua)
if err != nil {
return nil, nil, err
}
// Відкликаємо після створення наступника, а не до: інакше збій
// створення лишив би людину взагалі без сесії.
if _, err := s.bg.Exec(ctx,
`UPDATE core.sessions SET revoked_at = now(), replaced_by = $2 WHERE id = $1`,
oldID, sess.ID); 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.bg.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.bg.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.bg.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.bg.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
// InTenantTxBG, а не InTenantTx: ON CONFLICT (username) нижче має
// побачити людину, яка вже працює в ІНШОМУ кабінеті, — саме заради
// цього конфлікт і оброблюється. Під політиками читання core.users
// такий рядок невидимий, і RETURNING повернув би не «підхопили
// наявного», а помилку унікальності. Політикою це не лікується:
// щоб її обійти, треба зробити core.users видимою наскрізь.
err = s.InTenantTxBG(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
}
// MemberRef — учасник разом із роллю, яку він мав у цю мить.
//
// Потрібен журналу аудиту, і саме тому в ньому і ключ ролі, і назва.
// Ключ — те, за чим шукають і що не змінюється; назва — те, що людина
// впізнає, і те, чого через рік може вже не існувати: роль
// перейменують або зітруть, і рядок «роль 7f3a…» перестане відповідати
// на питання, заради якого журнал відкрили.
type MemberRef struct {
UserID string
Username string
RoleID string
RoleKey string
RoleName string
}
// SetRole змінює роль учасника команди.
//
// Повертає стан ДО і ПІСЛЯ — так само, як UpdateRole повертає два
// знімки прав. Причина та сама: «змінили роль» без обох половин не
// відповідає ні на що, а після UPDATE попередня роль не лишається ніде.
func (s *Store) SetRole(ctx context.Context, tenantID, userID, roleID string) (before, after MemberRef, err error) {
err = s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
// Читаємо під замком у тій самій транзакції: між читанням і
// записом уміщається друга правка тієї ж людини, і без замка
// журнал назвав би «попередньою» роль, якої вона вже не мала.
if err := tx.QueryRow(ctx, `
SELECT u.username::text, r.id::text, r.key::text, r.name
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 m.user_id = $2
FOR UPDATE OF m
`, tenantID, userID).Scan(&before.Username, &before.RoleID,
&before.RoleKey, &before.RoleName); err != nil {
if isNoRows(err) {
return ErrNotFound
}
return err
}
before.UserID = userID
// Роль звіряємо з тим самим предикатом, що й ListRoles:
// вбудована (tenant_id IS NULL) або своя. Зовнішній ключ
// пропустив би сюди роль СУСІДНЬОГО кабінету — вона існує, тож
// FK задоволений, — і людина отримала б набір прав, якого в
// переліку її кабінету немає й не було. Обробник уже вміє
// відповідати на це «учасника або роль не знайдено».
after = MemberRef{UserID: userID, Username: before.Username}
if err := tx.QueryRow(ctx, `
SELECT id::text, key::text, name FROM core.roles
WHERE id = $1 AND (tenant_id IS NULL OR tenant_id = $2)
`, roleID, tenantID).Scan(&after.RoleID, &after.RoleKey, &after.RoleName); err != nil {
if isNoRows(err) {
return ErrNotFound
}
return err
}
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
})
return before, after, err
}
// RemoveMember прибирає людину з тенанта.
//
// Сам користувач лишається: він може працювати в іншому тенанті, а його
// сліди в аудиті мають лишатися читабельними. Зате всі сесії саме в
// цьому тенанті відкликаються негайно.
func (s *Store) RemoveMember(ctx context.Context, tenantID, userID string) (MemberRef, error) {
gone := MemberRef{UserID: userID}
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
// Ім'я й роль — до видалення: рядок memberships зникає разом із
// відповіддю на «а що ця людина тут могла», і відновити її потім
// не з чого. Сам обліковий запис лишається, але він нічого не
// каже про доступ САМЕ в цьому кабінеті.
if err := tx.QueryRow(ctx, `
SELECT u.username::text, r.id::text, r.key::text, r.name
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 m.user_id = $2
FOR UPDATE OF m
`, tenantID, userID).Scan(&gone.Username, &gone.RoleID,
&gone.RoleKey, &gone.RoleName); err != nil {
if isNoRows(err) {
return ErrNotFound
}
return err
}
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
})
return gone, err
}
// ChangePassword міняє пароль і відкликає всі інші сесії.
func (s *Store) ChangePassword(ctx context.Context, userID, oldPassword, newPassword string) error {
var hash *string
if err := s.bg.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.bg.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)
}
// ---------------------------------------------------------------------
// Редагування користувача адміністратором
// ---------------------------------------------------------------------
// ErrSharedUser — обліковий запис належить кільком організаціям.
//
// core.users глобальна: одна людина може працювати в кількох тенантах
// (типово для MSP). Профіль, логін і пароль — її власність, а не
// власність організації, тому адмін одного тенанта не може міняти їх
// у того, хто працює ще десь. Роль і членство в групах — навпаки,
// локальні, і редагуються завжди.
var ErrSharedUser = errors.New("користувач працює в кількох організаціях")
// UserProfileInput — те, що адміністратор може змінити в профілі.
// Порожній рядок означає «не чіпати».
type UserProfileInput struct {
Username string
Email string
FullName string
Password string
}
// UpdateUserProfile міняє профіль учасника.
//
// Перед будь-якою зміною перевіряється, що людина належить лише цьому
// тенанту. Інакше адмін філії міг би змінити пароль людині, яка тим
// самим акаунтом заходить у сусідню організацію, — і та про це навіть
// не дізналася б.
// Повертає логін, який має обліковий запис ПІСЛЯ правки. Потрібен
// журналу: «змінили профіль користувачу 7f3a…» не відповідає на
// питання «кому», а перейменування — одна з тих правок, які тут і
// роблять.
func (s *Store) UpdateUserProfile(ctx context.Context, tenantID, userID string, in UserProfileInput) (string, error) {
var username string
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
var member bool
if err := tx.QueryRow(ctx, `
SELECT EXISTS (SELECT 1 FROM core.memberships
WHERE tenant_id = $1 AND user_id = $2)
`, tenantID, userID).Scan(&member); err != nil {
return err
}
if !member {
return ErrNotFound
}
var tenants int
if err := tx.QueryRow(ctx,
`SELECT count(*)::int FROM core.memberships WHERE user_id = $1`, userID).
Scan(&tenants); err != nil {
return err
}
if tenants > 1 {
return ErrSharedUser
}
var hash *string
if in.Password != "" {
h, err := auth.HashPassword(in.Password)
if err != nil {
return err
}
hash = &h
}
if err := tx.QueryRow(ctx, `
UPDATE core.users
SET username = COALESCE(NULLIF($2,'')::citext, username),
email = CASE WHEN $3 = '' THEN email
WHEN $3 = '-' THEN NULL
ELSE $3::citext END,
full_name = COALESCE(NULLIF($4,''), full_name),
password_hash = COALESCE($5, password_hash),
updated_at = now()
WHERE id = $1
RETURNING username::text
`, userID, in.Username, in.Email, in.FullName, hash).Scan(&username); err != nil {
return fmt.Errorf("оновлення профілю: %w", err)
}
// Зміна пароля відкликає сесії — інакше той, хто знав старий,
// лишається всередині рівно доти, доки не протермінується його
// refresh, тобто до місяця.
if in.Password != "" {
if _, err := tx.Exec(ctx, `
UPDATE core.sessions SET revoked_at = now()
WHERE user_id = $1 AND revoked_at IS NULL
`, userID); err != nil {
return err
}
}
return nil
})
return username, err
}
// UserByID читає обліковий запис для сторінки профілю та /me.
//
// Окремий запит, а не поле в токені доступу, і це свідомо. Токен живе
// 15 хвилин і підписаний: усе, що в нього покладено, після перейменування
// користувача лишається старим до кінця цього строку — тобто людина
// бачила б у шапці чуже, вже неіснуюче ім'я й не мала б способу це
// виправити. Ціна — один короткий SELECT на виклик, який робиться раз на
// завантаження сторінки.
func (s *Store) UserByID(ctx context.Context, userID string) (*User, error) {
var u User
err := s.bg.QueryRow(ctx, `
SELECT id::text, username, COALESCE(email,''), COALESCE(full_name,''),
COALESCE(locale,'uk'), COALESCE(timezone,'UTC'), mfa_enabled
FROM core.users WHERE id = $1
`, userID).Scan(&u.ID, &u.Username, &u.Email, &u.FullName,
&u.Locale, &u.Timezone, &u.MFA)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
return &u, nil
}