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>
820 lines
32 KiB
Go
820 lines
32 KiB
Go
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
|
||
}
|