Історія входів: 93 записи були в базі й ніде не показувались
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

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>
This commit is contained in:
byrsapty 2026-08-29 01:06:42 +03:00
parent f6df538020
commit 95a19b074d
11 changed files with 2518 additions and 8 deletions

View file

@ -0,0 +1,137 @@
package httpapi
import (
"net/http"
"strconv"
"strings"
"time"
"github.com/netpulse/netpulse/server/internal/store"
)
// «Історія входів»: читання core.login_attempts, і тільки читання.
//
// Рідна сестра журналу аудиту: ті самі два GET (перелік і довідники),
// той самий курсор замість offset, той самий закріплений період і той
// самий блок про межі показаного. Різниця одна, і вона в шарі сховища:
// у core.login_attempts немає tenant_id, тож ізоляція кабінетів там
// зроблена руками — див. шапку store/login_attempts.go.
//
// ПРАВО ТЕ САМЕ, ЩО В ЖУРНАЛУ АУДИТУ, і це не економія на переліку
// прав. Обидві сторінки відповідають на одне питання — «хто і що робив
// з нашим доступом», — і роздати їх різним людям означало б, що один
// бачить «видалив конфіги о 14:02», а другий «увійшов о 14:01 з чужої
// адреси». Розслідування — це рівно поєднання цих двох рядків.
// audit:read і так є лише у власника й адміністратора.
//
// Ручки, яка стирає рядок, тут немає й не буде — з тієї ж причини, що в
// audit.go: історія входів, яку можна підчистити, не доводить нічого.
const loginsPerm = auditPerm
// Стеля глибини вибірки — така сама, як у журналі. Ширшу межу тут
// ставити нема сенсу: спроби входу типово живуть 180 днів (0012), тобто
// глибше за півроку в таблиці порожньо в будь-якому разі.
const loginsMaxRange = auditMaxRange
func (s *Server) handleListLoginAttempts(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, loginsPerm) {
return
}
q := r.URL.Query()
f := store.LoginAttemptFilter{
Reasons: splitParam(q, "reason"),
UserIDs: splitParam(q, "user"),
IP: strings.TrimSpace(q.Get("ip")),
Query: strings.TrimSpace(q.Get("q")),
Cursor: q.Get("cursor"),
}
// Результат — три стани, а не прапорець. Значення, якого немає в
// переліку, ігнорується мовчки: сторінка кладе в адресу лише свої
// три, а чуже слово в чужому посиланні має дати повний перелік, а
// не відмову.
switch v := q.Get("outcome"); v {
case "failed", "success":
f.Outcome = v
}
var err error
if f.From, err = parseTimeParam(q.Get("from")); err != nil {
writeError(w, http.StatusBadRequest, "invalid", "некоректна дата «від»")
return
}
if f.To, err = parseTimeParam(q.Get("to")); err != nil {
writeError(w, http.StatusBadRequest, "invalid", "некоректна дата «до»")
return
}
// Верхня межа за замовчуванням — зараз, а не «без межі»: та сама
// причина, що в журналі аудиту. Вхід, який стався між першою
// сторінкою й наступною, інакше зсунув би всю вибірку на рядок.
if f.To.IsZero() {
f.To = time.Now().UTC()
}
if f.From.IsZero() {
// Типовий період коротший, ніж у журналі (30 днів), і це не
// різнобій. Сюди приходять із питанням «що відбувається зараз»,
// а не «що було в березні»: невдалі спроби цінні свіжими, і
// сім днів — це вікно, у якому їх ще можна з кимось звірити.
f.From = f.To.Add(-7 * 24 * time.Hour)
}
if !f.From.Before(f.To) {
writeError(w, http.StatusBadRequest, "invalid", "початок періоду має бути раніше за кінець")
return
}
if f.To.Sub(f.From) > loginsMaxRange {
writeError(w, http.StatusBadRequest, "invalid",
"період не більший за рік — звузьте діапазон або скористайтесь фільтрами")
return
}
if n, err := strconv.Atoi(q.Get("limit")); err == nil {
f.Limit = n
}
// Scope тут не застосовується — з тієї ж причини, що в журналі
// аудиту: він відповідає на «які ХОСТИ видно», а спроба входу не
// про хост. Межа видимості тут інша й жорсткіша — членство в
// кабінеті, і вона в сховищі.
page, err := s.store.ListLoginAttempts(r.Context(), p.TenantID, f)
if err != nil {
s.writeStoreError(w, "історія входів", err)
return
}
writeJSON(w, http.StatusOK, page)
}
// handleLoginAttemptsMeta — усе, що потрібно сторінці на відкритті,
// одним викликом. Одним, а не трьома: це стан сторінки на момент
// відкриття, і зібраний із трьох відповідей у різні секунди він нічого
// не виграє, зате додає два стани завантаження на екран.
func (s *Server) handleLoginAttemptsMeta(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, loginsPerm) {
return
}
actors, err := s.store.LoginActors(r.Context(), p.TenantID)
if err != nil {
s.writeStoreError(w, "перелік людей в історії входів", err)
return
}
bounds, err := s.store.LoginAttemptsBounds(r.Context(), p.TenantID)
if err != nil {
s.writeStoreError(w, "межі історії входів", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{
"reasons": store.LoginReasons(),
"actors": actors,
"bounds": bounds,
// Чого сторінка не бачить. Не оздоблення: саме сюди приходять
// із питанням «нас ламають?», і мовчазна порожнеча читалась би
// як «ні».
"blind_spots": store.LoginBlindSpots(),
"max_range_days": int(loginsMaxRange / (24 * time.Hour)),
})
}

View file

@ -400,6 +400,14 @@ func (s *Server) Handler() http.Handler {
mux.Handle("GET /api/v1/audit/meta", s.authenticated(s.handleAuditMeta)) mux.Handle("GET /api/v1/audit/meta", s.authenticated(s.handleAuditMeta))
mux.Handle("GET /api/v1/audit/{id}", s.authenticated(s.handleGetAuditEvent)) mux.Handle("GET /api/v1/audit/{id}", s.authenticated(s.handleGetAuditEvent))
// Історія входів — поруч із журналом і під тим самим правом
// (audit:read). Обидві сторінки відповідають на одне питання «хто і
// що робив з нашим доступом», і розслідування — це поєднання їхніх
// рядків. Ізоляція кабінетів тут зроблена руками: у
// core.login_attempts немає tenant_id. Див. logins.go.
mux.Handle("GET /api/v1/logins", s.authenticated(s.handleListLoginAttempts))
mux.Handle("GET /api/v1/logins/meta", s.authenticated(s.handleLoginAttemptsMeta))
mux.Handle("GET /api/v1/alerts", s.authenticated(s.handleListAlerts)) mux.Handle("GET /api/v1/alerts", s.authenticated(s.handleListAlerts))
mux.Handle("POST /api/v1/alerts/{id}/ack", s.authenticated(s.handleAckAlert)) mux.Handle("POST /api/v1/alerts/{id}/ack", s.authenticated(s.handleAckAlert))
mux.Handle("POST /api/v1/alerts/{id}/close", s.authenticated(s.handleCloseAlert)) mux.Handle("POST /api/v1/alerts/{id}/close", s.authenticated(s.handleCloseAlert))

View file

@ -0,0 +1,691 @@
package store
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"time"
"github.com/jackc/pgx/v5"
)
// Читання core.login_attempts — «Історія входів».
//
// Записує в цю таблицю recordLogin (users.go), і до сьогодні читало її
// рівно одне місце — FailedLoginsSince, лічильник для гальмування
// перебору. Тобто дані збиралися півтора року й не показувалися нікому.
//
// ГОЛОВНЕ ОБМЕЖЕННЯ ШАРУ: У ТАБЛИЦІ НЕМАЄ tenant_id.
//
// І не може бути: рядок пишеться в мить перевірки пароля, коли кабінет
// ще невідомий — саме тому спроби входу й не потрапили в журнал аудиту
// (перший пункт AuditBlindSpots). Наслідок для читання прямий: запит
// «покажи спроби входу» без додаткової умови віддає одному кабінету
// спроби входу ЧУЖИХ людей — з їхніми логінами, поштами й адресами.
// Це витік, і закривати його треба тут, а не на сторінці.
//
// ЯК РЯДОК ПРИВ'ЯЗУЄТЬСЯ ДО КАБІНЕТУ
//
// Прив'язка непряма — через людину, до якої спроба стосується:
//
// спроба → користувач → членство в кабінеті
//
// Користувач шукається трьома шляхами, і всі три потрібні:
//
// 1. a.user_id — заповнений у всіх випадках, крім «такого логіна
// немає». Найнадійніший шлях: він не залежить від того, чи людину
// потім перейменували.
// 2. u.username = a.email — бо в стовпці email лежить НЕ пошта, а те,
// що людина ввела в поле логіна (див. recordLogin: аргумент так і
// називається email, а передається туди login). Входять у продукт
// переважно логіном, тож без цього шляху половина спроб не
// прив'язалася б.
// 3. u.email = a.email — вхід поштою теж дозволений.
//
// РІШЕННЯ ПРО НЕПРИВ'ЯЗУВАНІ СПРОБИ
//
// Невдала спроба з логіном, якого в системі немає (reason = no_user),
// не належить жодному кабінету — прив'язати її НЕМА ДО ЧОГО. Те саме
// стосується спроби людини, яку вже вилучили з усіх кабінетів
// (reason = no_membership і членства більше немає).
//
// Розглянуто три варіанти, обрано третій.
//
// - «Показувати всім без пошти». Відкинуто. Логін приховано, але
// лишаються адреса й секунда — а цього досить: спроба о 09:12 з
// офісної адреси сусіднього кабінету з одруківкою в логіні — це
// їхній співробітник, і сусід його впізнає. Анонімізація, яку
// знімають двома відомими фактами, гірша за її відсутність, бо
// створює враження безпеки.
// - «Показувати власникові платформи». Відкинуто як нездійсненне:
// власник у NetPulse — це роль У КАБІНЕТІ (core.roles.key = owner),
// власника інсталяції в моделі немає взагалі. Реалізувати це
// означало б спершу вигадати нову роль поверх усієї моделі прав —
// тобто зробити зовсім іншу задачу.
// - ОБРАНО: рядками не показувати НІКОМУ, але порахувати й сказати
// число на сторінці. Рядок нікому не належить, і будь-який кабінет,
// якому його віддали, отримав чужі дані. А от «за цей період було 47
// невдалих спроб з логінами, яких у системі немає, з 3 адрес» —
// число, що не називає нікого й не звужується до людини, зате несе
// рівно той сигнал, заради якого сторінку й відкривають: логіни
// перебирають. Мовчазний нуль на місці хвилі перебору був би
// найгіршим з можливих результатів.
//
// Однозначно правильного варіанта тут немає — є найменш небезпечний, і
// обрано саме його: із трьох це єдиний, у якому жоден кабінет не
// отримує жодного рядка про чужу людину.
//
// РІШЕННЯ ПРО СПРОБУ ДО «ЧУЖОГО» КОРИСТУВАЧА
//
// Питання ставиться двічі й має дві різні відповіді.
//
// - Логін належить людині, якої у вашому кабінеті немає. Не ваша
// справа й не ваш рядок: ця людина працює в іншому кабінеті, і те,
// що хтось перебирає її пароль, — подія їхнього кабінету, де вона
// й буде видна. Показати її вам означало б віддати вам чужий
// обліковий запис і чужу адресу.
// - Логін належить людині, яка є І у вас, і в сусідньому кабінеті
// (у core.users користувач глобальний — див. 0011). Спроба видна
// ОБОМ кабінетам, і це навмисно: підібраний пароль цієї людини
// відкриває дані обох, тож «хтось підбирає пароль до вашого
// користувача» — правда для кожного з них однаково. Витік тут
// мінімальний: кабінет бачить власного учасника, якого й так знає
// на ім'я, а не нову для себе людину.
//
// ЧОМУ УМОВА СТОЇТЬ ЯВНО, А НЕ ПОКЛАДАЄТЬСЯ НА RLS
//
// core.login_attempts — гіпертаблиця без tenant_id: політики на ній
// немає й бути не може. Читання йде через InTenantTx, і RLS на
// core.users та core.memberships підстраховує LATERAL другим шаром
// (без контексту він поверне порожньо, тобто відмова буде в бік
// «нічого не видно»), але єдиний робочий механізм ізоляції — саме
// m.tenant_id = $1 нижче. Прибрати цей рядок = показати кабінету чужі
// спроби входу.
// LoginAttempt — рядок історії входів.
type LoginAttempt struct {
ID string `json:"id"`
TS time.Time `json:"ts"`
// Login — те, що НАСПРАВДІ ввели в поле логіна. Не завжди пошта, і
// не завжди збігається з поточним логіном людини: після
// перейменування в старих рядках лишається старе значення, і
// переписувати його не можна — це доказова база.
Login string `json:"login"`
Success bool `json:"success"`
// Причина відмови ключем і фразою. Ключ лишається поруч із тієї ж
// причини, що й у журналі аудиту: за ним фільтрують і його шлють у
// підтримку.
Reason string `json:"reason,omitempty"`
ReasonLabel string `json:"reason_label,omitempty"`
IP string `json:"ip,omitempty"`
// UserAgent як є, і поруч — стислий переказ. Переказ приблизний за
// побудовою (див. deviceFromUA), тому початковий рядок теж їде:
// коли розбирають конкретний вхід, вгадане «Chrome» нічого не варте.
UserAgent string `json:"user_agent,omitempty"`
Device string `json:"device,omitempty"`
// Кому спроба належить. Ім'я підтягується на момент ЧИТАННЯ — як у
// журналі аудиту: людину впізнають за поточним іменем.
UserID string `json:"user_id,omitempty"`
Username string `json:"username,omitempty"`
Email string `json:"email,omitempty"`
FullName string `json:"full_name,omitempty"`
}
// LoginAttemptFilter — питання, з яким приходять на сторінку.
type LoginAttemptFilter struct {
// Період обов'язковий і завжди заданий обробником: таблиця росте
// вічно (в межах строку зберігання), і запит без нижньої межі — це
// запит по всій історії.
From time.Time
To time.Time
// Outcome: "" — усе, "failed" — лише невдалі, "success" — лише
// вдалі. Рядок, а не *bool: значення приїздить із адресного рядка,
// і три стани там виражаються трьома словами, а не наявністю
// параметра.
Outcome string
// Причини відмови. Порожній перелік — усі.
Reasons []string
// Кого саме — ідентифікатори людей.
UserIDs []string
// Адреса або підмережа: '10.20.0.5' і '10.20.0.0/24' обидві
// працюють через один оператор <<=.
IP string
// Пошук підрядком по введеному логіну. Саме він, а не ім'я
// користувача: питання тут — «що вводили», і одруківка в логіні
// («adm1n») знаходиться тільки так.
Query string
Limit int
Cursor string
}
// LoginUnattributed — скільки невдалих спроб за той самий період не
// належать ЖОДНОМУ кабінету.
//
// Два числа й жодного рядка. Це і є компроміс, описаний у шапці файла:
// сигнал «логіни перебирають» лишається видимим, а логіни, адреси й
// час — ні. Кількість різних адрес відділяє «людина забула свій логін»
// від «ботнет ходить по словнику», і сама по собі нікого не називає.
type LoginUnattributed struct {
Attempts int `json:"attempts"`
IPs int `json:"ips"`
}
// LoginAttemptsPage — сторінка історії входів.
type LoginAttemptsPage struct {
// Період, у якому відповідь зібрана насправді — як у журналі
// аудиту: клієнт закріплює отриману пару й шле її з кожною
// наступною сторінкою, інакше новий вхід під час гортання зсунув би
// усю вибірку на рядок.
From time.Time `json:"from"`
To time.Time `json:"to"`
Attempts []LoginAttempt `json:"attempts"`
// Курсор наступної сторінки. Порожній означає «більше немає»: як і
// в журналі, сторінка береться з limit+1 рядка.
NextCursor string `json:"next_cursor,omitempty"`
// Заповнюється лише на ПЕРШІЙ сторінці (Cursor == ""). На «показати
// ще» число не змінюється — воно про весь період, а не про порцію,
// і рахувати його вдруге означало б платити зайвим запитом за той
// самий результат.
Unattributed *LoginUnattributed `json:"unattributed,omitempty"`
}
// LoginAttemptBounds — від якої дати в таблиці взагалі щось є ДЛЯ ЦЬОГО
// КАБІНЕТУ.
//
// Та сама причина, що й у AuditBounds: порожня відповідь на «покажи
// березень» означає «у березні ще не писали» або «до березня ще ніхто
// не входив», а не «входів не було». Різницю сторінка мусить показати
// сама.
type LoginAttemptBounds struct {
First *time.Time `json:"first,omitempty"`
Last *time.Time `json:"last,omitempty"`
}
// LoginActorOption — рядок у фільтрі «хто».
type LoginActorOption struct {
ID string `json:"id"`
Label string `json:"label"`
}
// LoginReasonInfo — причина відмови: ключ і його людська назва.
type LoginReasonInfo struct {
Key string `json:"key"`
Label string `json:"label"`
}
const (
loginDefaultLimit = 50
loginMaxLimit = 200
)
// ---------------------------------------------------------------------
// Словник причин
// ---------------------------------------------------------------------
// Причини відмови у вході.
//
// Значення — рядки, які кладе recordLogin (users.go); змінювати їх
// заднім числом не можна, бо в таблиці вже лежать старі. Ключа, якого
// тут немає, це не ламає: причина показується самим лише ключем — так
// само, як дія без назви в журналі аудиту.
//
// Порядок навмисно не абетковий, а за небезпечністю: bad_password —
// перше, на що дивляться, бо саме воно означає «пароль підбирають до
// відомого логіна».
var loginReasons = []LoginReasonInfo{
{Key: "bad_password", Label: "Невірний пароль"},
{Key: "no_user", Label: "Такого логіна немає"},
{Key: "no_membership", Label: "Немає доступу до жодного кабінету"},
{Key: "no_password", Label: "Локального пароля немає (лише SSO)"},
{Key: "locked", Label: "Обліковий запис заблоковано"},
{Key: "mfa_failed", Label: "Не пройдено другий фактор"},
}
var loginReasonByKey = func() map[string]string {
m := make(map[string]string, len(loginReasons))
for _, r := range loginReasons {
m[r.Key] = r.Label
}
return m
}()
// LoginReasons — словник причин для фільтрів сторінки.
func LoginReasons() []LoginReasonInfo {
out := make([]LoginReasonInfo, len(loginReasons))
copy(out, loginReasons)
return out
}
// LoginBlindSpots — чесний перелік того, чого «Історія входів» НЕ
// показує.
//
// Той самий блок і та сама причина, що в AuditBlindSpots: сторінка без
// нього створює хибне відчуття повноти. Тут це особливо дорого — саме
// сюди приходять із питанням «нас ламають?», і мовчазна порожнеча
// читається як «ні».
//
// Перелік живе на сервері, а не в браузері, бо кожен його рядок — це
// властивість того, що продукт ЗАПИСУЄ (або не записує), а не того, як
// це показано.
func LoginBlindSpots() []string {
return []string{
"Спроби входу людей з інших кабінетів. У core.login_attempts немає tenant_id — рядок пишеться тоді, коли кабінет ще невідомий, — тож спроба прив'язується непрямо: через логін або пошту до користувача, а через нього до членства у вашому кабінеті. Спроба до людини, якої у вас немає, належить її кабінету й тут не показується.",
"Невдалі спроби з логіном, якого в системі немає взагалі. Прив'язати їх нема до чого, а віддати комусь означало б віддати чужі дані, тож рядками вони не показуються НІКОМУ. Замість рядків — лічильник угорі: скільки таких спроб і з якої кількості адрес. Це навмисний компроміс, а не недогляд.",
"Спроби, відбиті гальмуванням перебору. Після 10 невдалих спроб за 15 хвилин вхід відмовляє ще ДО перевірки пароля, і рядок у таблицю не пишеться взагалі. Тобто справжня хвиля перебору тут виглядає рівно як 10 спроб і тиша.",
"Вихід із системи, оновлення й відкликання сесій. Таблиця називається «спроби входу» буквально: усе, що відбувається з сесією після нього, у ній не з'являється.",
"Вхід машинних токенів і зондів. Вони не проходять через перевірку пароля, а отже й через цю таблицю; їхні дії видно в журналі аудиту з позначкою токена.",
"У який саме кабінет людина увійшла. У рядку цього немає, і додати заднім числом нема звідки. Тому вхід людини, яка працює у двох кабінетах, видно в обох — навіть якщо вона того разу зайшла лише в один.",
"Те, що вже видалено за строком зберігання. Типово спроби входу живуть 180 днів; строк налаштовується в розділі «Сховище» (kind login_attempts) і не може бути меншим за 30 днів.",
}
}
// ---------------------------------------------------------------------
// Вибірка
// ---------------------------------------------------------------------
// ListLoginAttempts повертає сторінку історії входів для кабінету.
func (s *Store) ListLoginAttempts(ctx context.Context, tenantID string, f LoginAttemptFilter) (LoginAttemptsPage, error) {
limit := f.Limit
if limit <= 0 {
limit = loginDefaultLimit
}
if limit > loginMaxLimit {
limit = loginMaxLimit
}
from, args, err := loginAttemptsFrom(tenantID, f)
if err != nil {
return LoginAttemptsPage{}, err
}
// На один рядок більше, ніж показуємо: він і є відповіддю на «чи є
// наступна сторінка». Те саме рішення, що в ListAudit.
args = append(args, limit+1)
q := `
SELECT a.ts, a.id::text, a.email::text, a.success, a.reason,
host(a.ip), a.user_agent,
usr.uid, usr.uname, usr.uemail, usr.ufull
` + from + `
ORDER BY a.ts DESC, a.id DESC
LIMIT $` + strconv.Itoa(len(args))
// InTenantTx потрібен не самій таблиці спроб (політик на ній
// немає), а LATERAL: core.users і core.memberships під RLS, і без
// app.tenant_id жодна спроба не прив'язалася б. Тобто забутий
// контекст дає порожній екран, а не чужі рядки, — відмова в
// безпечний бік.
out := LoginAttemptsPage{From: f.From, To: f.To, Attempts: []LoginAttempt{}}
err = s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
rows, err := tx.Query(ctx, q, args...)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
a, err := scanLoginAttempt(rows)
if err != nil {
return err
}
out.Attempts = append(out.Attempts, a)
}
return rows.Err()
})
if err != nil {
return LoginAttemptsPage{}, err
}
if len(out.Attempts) > limit {
last := out.Attempts[limit-1]
out.Attempts = out.Attempts[:limit]
// Курсор той самий, що в журналі аудиту, і функція та сама:
// первинний ключ тут теж (ts, id), і друга копія на п'ять
// рядків розійшлася б із першою на першій же правці.
out.NextCursor = encodeAuditCursor(last.TS, last.ID)
}
// Межу видимості рахуємо лише для першої сторінки — див. коментар
// до поля Unattributed.
if f.Cursor == "" {
u, err := s.unattributedLogins(ctx, f.From, f.To)
if err != nil {
return LoginAttemptsPage{}, err
}
out.Unattributed = &u
}
return out, nil
}
// loginAttemptsFrom складає FROM + WHERE і аргументи. $1 завжди
// tenant_id.
//
// Одна функція на джерело й умову, а не дві: прив'язка до кабінету
// живе саме в LATERAL, і рознести її з рештою умов означало б лишити
// місце, де можна зібрати запит без неї. Тест
// (login_attempts_test.go) перевіряє цю функцію на кожній комбінації
// фільтрів — саме тому, що вона одна.
func loginAttemptsFrom(tenantID string, f LoginAttemptFilter) (string, []any, error) {
if strings.TrimSpace(tenantID) == "" {
// Порожній кабінет — не «показати все», а помилка. Без цього
// рядка виклик із незаповненим tenantID зібрав би запит, у
// якому m.tenant_id = '' не збігається ні з чим… на щастя. Але
// покладатись на «на щастя» в умові, що відділяє кабінети,
// не можна.
return "", nil, fmt.Errorf("%w: не вказано кабінет", ErrInvalid)
}
args := []any{tenantID}
// LATERAL робить дві речі одночасно й навмисно: знаходить людину,
// до якої спроба стосується, і ВІДСІЮЄ рядок, якщо такої людини в
// цьому кабінеті немає. Саме тому це JOIN, а не LEFT JOIN: підзапит
// без збігів не дає рядка, і спроба зникає з вибірки.
//
// LIMIT 1 всередині — бо в кабінеті може знайтись і збіг за
// user_id, і збіг за логіном (перейменували людину, а стару назву
// зайняв хтось інший). ORDER BY ставить попереду точний збіг за
// user_id: він не залежить від перейменувань.
from := `
FROM core.login_attempts a
JOIN LATERAL (
SELECT u.id::text AS uid, u.username::text AS uname,
u.email::text AS uemail, coalesce(u.full_name, '') AS ufull
FROM core.users u
JOIN core.memberships m ON m.user_id = u.id AND m.tenant_id = $1
WHERE u.id = a.user_id
OR u.username = a.email
OR u.email = a.email
ORDER BY (u.id IS NOT DISTINCT FROM a.user_id) DESC
LIMIT 1
) usr ON true
WHERE `
var cond []string
add := func(sql string, v any) {
args = append(args, v)
cond = append(cond, fmt.Sprintf(sql, len(args)))
}
if !f.From.IsZero() {
add("a.ts >= $%d", f.From)
}
if !f.To.IsZero() {
add("a.ts < $%d", f.To)
}
switch f.Outcome {
case "failed":
cond = append(cond, "NOT a.success")
case "success":
cond = append(cond, "a.success")
}
if len(f.Reasons) > 0 {
add("a.reason = ANY($%d)", f.Reasons)
}
if len(f.UserIDs) > 0 {
add("usr.uid = ANY($%d)", f.UserIDs)
}
if ip := strings.TrimSpace(f.IP); ip != "" {
add("a.ip <<= $%d::inet", ip)
}
if q := strings.TrimSpace(f.Query); q != "" {
add("a.email::text ILIKE $%d", "%"+escapeLike(q)+"%")
}
if f.Cursor != "" {
ts, id, err := decodeAuditCursor(f.Cursor)
if err != nil {
return "", nil, err
}
args = append(args, ts, id)
cond = append(cond, fmt.Sprintf("(a.ts, a.id) < ($%d, $%d)", len(args)-1, len(args)))
}
// Умов може не бути жодної (порожній фільтр). Порожній WHERE — це
// синтаксична помилка, тож TRUE тримає форму запиту сталою; сама
// прив'язка до кабінету від цього не залежить — вона в JOIN вище.
if len(cond) == 0 {
cond = append(cond, "TRUE")
}
return from + strings.Join(cond, "\n\t\t AND "), args, nil
}
func scanLoginAttempt(rows pgx.Rows) (LoginAttempt, error) {
var (
a LoginAttempt
reason *string
ip *string
ua *string
uid *string
uname *string
uemail *string
ufull *string
)
if err := rows.Scan(&a.TS, &a.ID, &a.Login, &a.Success, &reason,
&ip, &ua, &uid, &uname, &uemail, &ufull); err != nil {
return LoginAttempt{}, err
}
a.TS = a.TS.UTC()
a.Reason = deref(reason)
a.ReasonLabel = loginReasonByKey[a.Reason]
a.IP = deref(ip)
a.UserAgent = deref(ua)
a.Device = deviceFromUA(a.UserAgent)
a.UserID = deref(uid)
a.Username = deref(uname)
a.Email = deref(uemail)
a.FullName = deref(ufull)
return a, nil
}
// unattributedLogins рахує невдалі спроби, які не належать жодному
// кабінету.
//
// ЄДИНЕ МІСЦЕ ФАЙЛА, ЯКЕ ХОДИТЬ ПОВЗ КАБІНЕТ, — і це неминуче за
// змістом питання. «Не належить ЖОДНОМУ кабінету» неможливо з'ясувати,
// бачачи один: під InTenantTx політика на core.users сховала б людей
// сусідів, і їхні невдалі спроби порахувались би як нічиї — тобто
// кабінет дізнався б, скільки разів помилялися сусіди. Це був би витік
// рівно там, де ми його закриваємо.
//
// Тому запит іде пулом воркера (bg), який бачить усе. Назовні з нього
// виходять ДВА ЧИСЛА — жодного логіна, жодної адреси, жодного часу.
func (s *Store) unattributedLogins(ctx context.Context, from, to time.Time) (LoginUnattributed, error) {
var u LoginUnattributed
err := s.bg.QueryRow(ctx, `
SELECT count(*)::int, count(DISTINCT a.ip)::int
FROM core.login_attempts a
WHERE a.ts >= $1 AND a.ts < $2 AND NOT a.success
AND NOT EXISTS (
SELECT 1
FROM core.users u
JOIN core.memberships m ON m.user_id = u.id
WHERE u.id = a.user_id
OR u.username = a.email
OR u.email = a.email
)
`, from, to).Scan(&u.Attempts, &u.IPs)
if err != nil {
return LoginUnattributed{}, err
}
return u, nil
}
// LoginAttemptsBounds — перша й остання спроба, видима цьому кабінету.
func (s *Store) LoginAttemptsBounds(ctx context.Context, tenantID string) (LoginAttemptBounds, error) {
// Прив'язка та сама, що у вибірці, і зібрана тією ж функцією: два
// різні визначення «наша спроба» розійшлися б, і сторінка казала б
// «записи з 3 березня», не показуючи жодного за березень.
from, args, err := loginAttemptsFrom(tenantID, LoginAttemptFilter{})
if err != nil {
return LoginAttemptBounds{}, err
}
var b LoginAttemptBounds
err = s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
// Два запити з LIMIT 1, а не min()/max(): впорядкований обхід
// гіпертаблиці зупиняється на першому ж чанку, у якому щось є.
err := tx.QueryRow(ctx,
"SELECT a.ts "+from+"\n\t\tORDER BY a.ts ASC, a.id ASC LIMIT 1", args...,
).Scan(&b.First)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return err
}
err = tx.QueryRow(ctx,
"SELECT a.ts "+from+"\n\t\tORDER BY a.ts DESC, a.id DESC LIMIT 1", args...,
).Scan(&b.Last)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return err
}
return nil
})
if err != nil {
return LoginAttemptBounds{}, err
}
if b.First != nil {
t := b.First.UTC()
b.First = &t
}
if b.Last != nil {
t := b.Last.UTC()
b.Last = &t
}
return b, nil
}
// LoginActors — люди кабінету, до яких є хоч одна спроба входу.
//
// Саме «до яких є спроба», а не весь склад команди: фільтр «хто» має
// пропонувати те, що в переліку справді трапляється, інакше половина
// значень дає гарантовано порожній результат.
func (s *Store) LoginActors(ctx context.Context, tenantID string) ([]LoginActorOption, error) {
from, args, err := loginAttemptsFrom(tenantID, LoginAttemptFilter{})
if err != nil {
return nil, err
}
out := []LoginActorOption{}
err = s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
rows, err := tx.Query(ctx,
"SELECT DISTINCT usr.uid, usr.uname "+from+"\n\t\tORDER BY usr.uname", args...)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var o LoginActorOption
if err := rows.Scan(&o.ID, &o.Label); err != nil {
return err
}
out = append(out, o)
}
return rows.Err()
})
if err != nil {
return nil, err
}
return out, nil
}
// ---------------------------------------------------------------------
// Пристрій і браузер
// ---------------------------------------------------------------------
// deviceFromUA переказує User-Agent людською фразою.
//
// Переказ ПРИБЛИЗНИЙ, і це не лінощі, а властивість самого User-Agent:
// рядок задає клієнт, будь-хто може написати там що завгодно, а
// браузери десятиліттями брешуть один про одного («Chrome» усередині
// Edge, «Safari» усередині всіх). Тягнути сюди бібліотеку розбору
// означало б отримати ту саму здогадку, лише довшу.
//
// Тому правило одне: здогадка йде поруч із початковим рядком, ніколи
// замість нього. Порожній результат — теж відповідь: сторінка покаже
// User-Agent як є.
//
// Порядок перевірок має значення: Edge несе в собі Chrome, Chrome —
// Safari, тож шукати треба від найспецифічнішого.
func deviceFromUA(ua string) string {
if strings.TrimSpace(ua) == "" {
return ""
}
browser := ""
switch {
case strings.Contains(ua, "Edg/"), strings.Contains(ua, "Edge/"):
browser = "Edge"
case strings.Contains(ua, "OPR/"), strings.Contains(ua, "Opera"):
browser = "Opera"
case strings.Contains(ua, "YaBrowser"):
browser = "Yandex"
case strings.Contains(ua, "Firefox/"):
browser = "Firefox"
case strings.Contains(ua, "Chrome/"), strings.Contains(ua, "Chromium/"):
browser = "Chrome"
case strings.Contains(ua, "Safari/"):
browser = "Safari"
// Не браузери. Їх варто називати вголос: вхід із curl або скрипта —
// це не «людина за столом», і сплутати ці два випадки в розборі
// інциденту найдорожче.
case strings.HasPrefix(ua, "curl/"):
return "curl (скрипт)"
case strings.HasPrefix(ua, "Wget/"):
return "wget (скрипт)"
case strings.HasPrefix(ua, "Go-http-client"):
return "Go (скрипт)"
case strings.HasPrefix(ua, "python-requests"), strings.HasPrefix(ua, "Python-urllib"):
return "Python (скрипт)"
case strings.HasPrefix(ua, "PostmanRuntime"):
return "Postman"
case strings.Contains(ua, "NetPulse"):
return "NetPulse (зонд або CLI)"
}
os := ""
switch {
case strings.Contains(ua, "Windows NT"):
os = "Windows"
case strings.Contains(ua, "Android"):
os = "Android"
// iPhone/iPad перевіряються ДО Mac OS X: у мобільній Safari
// присутні обидва рядки.
case strings.Contains(ua, "iPhone"):
os = "iPhone"
case strings.Contains(ua, "iPad"):
os = "iPad"
case strings.Contains(ua, "Mac OS X"), strings.Contains(ua, "Macintosh"):
os = "macOS"
case strings.Contains(ua, "Linux"), strings.Contains(ua, "X11"):
os = "Linux"
}
switch {
case browser != "" && os != "":
return browser + " · " + os
case browser != "":
return browser
case os != "":
return os
default:
return ""
}
}

View file

@ -0,0 +1,413 @@
package store
import (
"context"
"os"
"regexp"
"strconv"
"strings"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// Відсіювання чужого в історії входів.
//
// ЧОМУ ЦЕЙ ФАЙЛ ІСНУЄ. У core.login_attempts немає tenant_id, тож
// ізоляція кабінетів тут не тримається ні на RLS, ні на предикаті по
// стовпцю — вона тримається на одному JOIN LATERAL у
// loginAttemptsFrom. Помилка в ньому не падає й не порожнить екран:
// сторінка малюється, рядки на місці, просто серед них чужі — з
// логінами, поштами й адресами людей із сусіднього кабінету. Побачити
// це можна лише знаючи, кого в кабінеті бути не може.
//
// Тому перевірок дві, і вони різні за силою.
//
// 1. Проти рядка запиту — працює завжди, зокрема на машині без
// Postgres. Ловить найімовірнішу поломку: умову прибрали або вона
// зникла для якоїсь комбінації фільтрів.
// 2. Проти БАЗИ — доводить не наявність рядка в SQL, а результат.
// Мовчки пропускається без NETPULSE_TEST_DSN (scripts/dbtest.sh).
// ---------------------------------------------------------------------
// 1. Умова кабінету є завжди
// ---------------------------------------------------------------------
func loginFilterCases() map[string]LoginAttemptFilter {
now := time.Now().UTC()
return map[string]LoginAttemptFilter{
"порожній": {},
"період": {From: now.Add(-24 * time.Hour), To: now},
"лише невдалі": {Outcome: "failed"},
"лише вдалі": {Outcome: "success"},
"причини": {Reasons: []string{"bad_password", "no_user"}},
"люди": {UserIDs: []string{"9b8e1737-5661-4b9c-b14e-ed8cd382145b"}},
"підмережа": {IP: "10.20.0.0/24"},
"пошук": {Query: "adm1n"},
"курсор": {
Cursor: encodeAuditCursor(now, "9b8e1737-5661-4b9c-b14e-ed8cd382145b"),
},
"усе разом": {
From: now.Add(-24 * time.Hour),
To: now,
Outcome: "failed",
Reasons: []string{"bad_password"},
UserIDs: []string{"9b8e1737-5661-4b9c-b14e-ed8cd382145b"},
IP: "10.20.0.5",
Query: "adm1n",
Cursor: encodeAuditCursor(now, "9b8e1737-5661-4b9c-b14e-ed8cd382145b"),
},
}
}
// Головна перевірка файла: жодна комбінація фільтрів не збирає запит
// без прив'язки до кабінету.
//
// Перевіряється не «десь є слово tenant», а три речі одночасно: умова
// стоїть на $1, вона всередині LATERAL (тобто відсікає рядок, а не
// просто добирає ім'я), і сам LATERAL приєднаний як JOIN, а не LEFT
// JOIN. Останнє — найтихіша з можливих поломок: одне слово перетворює
// відсіювання на «добери ім'я, якщо знайдеш», і вибірка починає
// віддавати ЧУЖІ спроби з порожньою колонкою «хто».
func TestLoginAttemptsAlwaysScopedToTenant(t *testing.T) {
for name, f := range loginFilterCases() {
t.Run(name, func(t *testing.T) {
from, args, err := loginAttemptsFrom("00000000-0000-0000-0000-000000000001", f)
if err != nil {
t.Fatalf("loginAttemptsFrom: %v", err)
}
if !strings.Contains(from, "m.tenant_id = $1") {
t.Fatalf("немає предикату кабінету:\n%s", from)
}
if !strings.Contains(from, "JOIN LATERAL") {
t.Fatalf("прив'язка більше не через LATERAL:\n%s", from)
}
if strings.Contains(from, "LEFT JOIN LATERAL") {
t.Fatalf("LEFT JOIN LATERAL не відсікає чужі рядки — лише не добирає ім'я:\n%s", from)
}
// Предикат мусить стояти ВСЕРЕДИНІ підзапиту, інакше він
// нічого не обмежує.
open := strings.Index(from, "JOIN LATERAL")
closeAt := strings.Index(from, ") usr")
tenantAt := strings.Index(from, "m.tenant_id = $1")
if !(open < tenantAt && tenantAt < closeAt) {
t.Fatalf("предикат кабінету поза LATERAL:\n%s", from)
}
if args[0] != "00000000-0000-0000-0000-000000000001" {
t.Fatalf("$1 — не кабінет, а %v", args[0])
}
})
}
}
// Нумерація плейсхолдерів — те, що ламається мовчки.
//
// Помилка на одиницю тут не падає: запит виконується, просто порівнює
// не ту колонку не з тим значенням. На сторінці, яку відкривають з
// питанням «чи нас ламають», це неправильна відповідь із виглядом
// правильної.
func TestLoginAttemptsPlaceholdersMatchArgs(t *testing.T) {
ph := regexp.MustCompile(`\$(\d+)`)
for name, f := range loginFilterCases() {
t.Run(name, func(t *testing.T) {
from, args, err := loginAttemptsFrom("00000000-0000-0000-0000-000000000001", f)
if err != nil {
t.Fatalf("loginAttemptsFrom: %v", err)
}
seen := map[int]bool{}
maxN := 0
for _, m := range ph.FindAllStringSubmatch(from, -1) {
n, _ := strconv.Atoi(m[1])
seen[n] = true
if n > maxN {
maxN = n
}
}
if maxN != len(args) {
t.Fatalf("найбільший плейсхолдер $%d, а аргументів %d\n%s", maxN, len(args), from)
}
for i := 1; i <= len(args); i++ {
if !seen[i] {
t.Fatalf("аргумент $%d не використано\n%s", i, from)
}
}
})
}
}
// Порожній кабінет — відмова, а не «показати все».
//
// Виклик без tenantID сьогодні дав би запит, у якому порожній рядок не
// збігається ні з чим, тобто порожню відповідь — «на щастя». Умова, що
// відділяє кабінети, не має триматись на щасті.
func TestLoginAttemptsRejectsEmptyTenant(t *testing.T) {
for _, id := range []string{"", " "} {
if _, _, err := loginAttemptsFrom(id, LoginAttemptFilter{}); err == nil {
t.Fatalf("порожній кабінет (%q) прийнято без помилки", id)
}
}
}
// Кожен ключ причини зі словника має назву, і навпаки — жодної назви
// без ключа. Ключі беруться з recordLogin (users.go): якщо там
// з'явиться нова причина, вона покажеться сирим рядком, і це
// припустимо — а от назва, прив'язана до ключа з одруківкою, брехала б.
func TestLoginReasonsDictionary(t *testing.T) {
for _, r := range LoginReasons() {
if r.Key == "" || r.Label == "" {
t.Fatalf("порожній рядок словника: %+v", r)
}
if loginReasonByKey[r.Key] != r.Label {
t.Fatalf("ключ %q не знайшовся в мапі", r.Key)
}
}
// Ті чотири, які продукт справді пише сьогодні. Перелік звірений із
// викликами recordLogin у store/users.go.
for _, key := range []string{"bad_password", "no_user", "no_membership", "no_password"} {
if loginReasonByKey[key] == "" {
t.Fatalf("причина %q, яку продукт пише, лишилась без назви", key)
}
}
}
func TestDeviceFromUA(t *testing.T) {
cases := map[string]string{
"": "",
// Edge несе в собі і Chrome, і Safari — саме тому порядок
// перевірок у deviceFromUA має значення.
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36 Edg/120.0": "Edge · Windows",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36": "Chrome · Windows",
"Mozilla/5.0 (X11; Linux x86_64; rv:121.0) Gecko/20100101 Firefox/121.0": "Firefox · Linux",
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 Version/17.0 Mobile/15E148 Safari/604.1": "Safari · iPhone",
// Не браузер. Сплутати скрипт із людиною за столом — найдорожча
// помилка в розборі інциденту.
"curl/8.4.0": "curl (скрипт)",
"Go-http-client/1.1": "Go (скрипт)",
"щось геть невідоме": "",
}
for ua, want := range cases {
if got := deviceFromUA(ua); got != want {
t.Errorf("deviceFromUA(%q) = %q, очікували %q", ua, got, want)
}
}
}
// ---------------------------------------------------------------------
// 2. Проти бази: чужого не видно
// ---------------------------------------------------------------------
// Доказ того, що відсіювання працює, а не оголошене.
//
// Перевірка проти рядка запиту вище не бачить різниці між «умова є» і
// «умова діє»: помилитись можна в самому зіставленні (шукати u.email
// там, де в стовпці лежить логін), і SQL лишиться правильним на вигляд.
// Тому тут — два справжні кабінети, п'ять справжніх рядків і питання до
// бази.
//
// Тест мовчки пропускається без NETPULSE_TEST_DSN. База мусить бути
// ОДНОРАЗОВОЮ — запускати через scripts/dbtest.sh.
func TestLoginAttemptsTenantIsolation(t *testing.T) {
dsn := os.Getenv("NETPULSE_TEST_DSN")
if dsn == "" {
t.Skip("NETPULSE_TEST_DSN не задано — перевірка проти бази пропускається")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatalf("підключення: %v", err)
}
t.Cleanup(pool.Close)
st := &Store{pool: pool, bg: pool}
suffix := strings.ReplaceAll(time.Now().Format("150405.000"), ".", "")
a := seedLoginTenant(ctx, t, pool, "logins-a-"+suffix)
b := seedLoginTenant(ctx, t, pool, "logins-b-"+suffix)
now := time.Now().UTC()
// Наливаємо п'ять спроб: дві кабінету A, дві кабінету B і одну
// нічию. Кожна — окремий шлях прив'язки, і всі три шляхи тут є:
// за user_id, за логіном і за поштою.
attempt := func(login string, userID *string, ok bool, reason string, ip string, ago time.Duration) {
t.Helper()
var r any
if reason != "" {
r = reason
}
_, err := pool.Exec(ctx, `
INSERT INTO core.login_attempts (ts, email, ip, user_agent, success, reason, user_id)
VALUES ($1, $2, $3::inet, $4, $5, $6, $7)
`, now.Add(-ago), login, ip, "curl/8.4.0", ok, r, userID)
if err != nil {
t.Fatalf("спроба %q: %v", login, err)
}
}
t.Cleanup(func() {
bg := context.Background()
// login_attempts ні на що не каскадує — прибираємо самі, і саме
// за тими логінами, які завели.
_, _ = pool.Exec(bg, `DELETE FROM core.login_attempts WHERE email = ANY($1)`,
[]string{a.username, a.email, b.username, b.email, "ніхто-" + suffix})
})
attempt(a.username, &a.userID, true, "", "10.10.0.1", 5*time.Minute) // A, за user_id
attempt(a.email, nil, false, "bad_password", "10.10.0.2", 6*time.Minute) // A, за поштою
attempt(b.username, &b.userID, false, "bad_password", "10.20.0.1", 7*time.Minute) // B, за логіном
attempt(b.email, nil, false, "bad_password", "10.20.0.2", 8*time.Minute) // B, за поштою
attempt("ніхто-"+suffix, nil, false, "no_user", "203.0.113.9", 9*time.Minute) // нічия
f := LoginAttemptFilter{From: now.Add(-time.Hour), To: now.Add(time.Minute)}
// Половина перша, без якої друга нічого не доводить: СВОЄ видно.
// Саме на цьому й горять перевірки ізоляції — вони зеленіють від
// того, що не видно нічого.
pageA, err := st.ListLoginAttempts(ctx, a.tenantID, f)
if err != nil {
t.Fatalf("перелік для A: %v", err)
}
if len(pageA.Attempts) != 2 {
t.Fatalf("кабінет A бачить %d спроб замість 2: %+v", len(pageA.Attempts), logins(pageA))
}
// Половина друга: ЧУЖОГО не видно — ні рядком, ні логіном.
for _, at := range pageA.Attempts {
if at.UserID != a.userID {
t.Fatalf("у кабінеті A спроба чужої людини: %+v", at)
}
if strings.EqualFold(at.Login, b.username) || strings.EqualFold(at.Login, b.email) {
t.Fatalf("у кабінеті A видно логін кабінету B: %q", at.Login)
}
if at.Login == "ніхто-"+suffix {
t.Fatalf("у кабінеті A видно неприв'язувану спробу: %q", at.Login)
}
}
// Дзеркально для B — інакше «A бачить дві» могло б означати, що
// зіставлення випадково збіглося саме на цих рядках.
pageB, err := st.ListLoginAttempts(ctx, b.tenantID, f)
if err != nil {
t.Fatalf("перелік для B: %v", err)
}
if len(pageB.Attempts) != 2 {
t.Fatalf("кабінет B бачить %d спроб замість 2: %+v", len(pageB.Attempts), logins(pageB))
}
for _, at := range pageB.Attempts {
if at.UserID != b.userID {
t.Fatalf("у кабінеті B спроба чужої людини: %+v", at)
}
}
// Нічия спроба не показана нікому, але порахована — і в обох
// кабінетах однаково, бо вона нічия для обох.
for _, p := range []struct {
name string
page LoginAttemptsPage
}{{"A", pageA}, {"B", pageB}} {
if p.page.Unattributed == nil {
t.Fatalf("кабінет %s не отримав межі видимості", p.name)
}
if p.page.Unattributed.Attempts < 1 {
t.Fatalf("кабінет %s: неприв'язувані спроби не пораховані (%d)",
p.name, p.page.Unattributed.Attempts)
}
}
// Причина відмови й переказ User-Agent доїжджають до рядка: без
// них сторінка показала б «невдало» без відповіді на «чому» і
// «звідки».
var failed *LoginAttempt
for i := range pageA.Attempts {
if !pageA.Attempts[i].Success {
failed = &pageA.Attempts[i]
}
}
if failed == nil {
t.Fatal("невдала спроба кабінету A не потрапила у вибірку")
}
if failed.ReasonLabel == "" {
t.Fatalf("причина %q лишилась без назви", failed.Reason)
}
if failed.Device != "curl (скрипт)" {
t.Fatalf("переказ User-Agent = %q", failed.Device)
}
if failed.Username != a.username {
t.Fatalf("спроба не прив'язалась до людини: %+v", failed)
}
// Межі й перелік людей рахуються ТІЄЮ Ж умовою — інакше сторінка
// казала б «записи з 3 березня», не показуючи жодного за березень.
actors, err := st.LoginActors(ctx, a.tenantID)
if err != nil {
t.Fatalf("перелік людей A: %v", err)
}
if len(actors) != 1 || actors[0].Label != a.username {
t.Fatalf("у фільтрі «хто» кабінету A: %+v", actors)
}
}
type loginTenant struct {
tenantID string
userID string
username string
email string
}
// seedLoginTenant заводить кабінет із рівно одним учасником.
//
// Свій, а не seedTenant із rls_isolation_test.go: тому потрібні хости,
// лінки й доступи, а тут — людина з логіном і поштою, і нічого більше.
func seedLoginTenant(ctx context.Context, t *testing.T, pool *pgxpool.Pool, slug string) loginTenant {
t.Helper()
r := loginTenant{
username: slug + "-user",
email: slug + "@example.test",
}
must := func(what string, err error) {
t.Helper()
if err != nil {
t.Fatalf("%s (%s): %v", what, slug, err)
}
}
must("кабінет", pool.QueryRow(ctx, `
INSERT INTO core.tenants (slug, name) VALUES ($1, $2) RETURNING id::text
`, slug, "Перевірка входів "+slug).Scan(&r.tenantID))
t.Cleanup(func() {
bg := context.Background()
_, _ = pool.Exec(bg, `DELETE FROM core.tenants WHERE id = $1`, r.tenantID)
_, _ = pool.Exec(bg, `DELETE FROM core.users WHERE username = $1`, r.username)
})
must("користувач", pool.QueryRow(ctx, `
INSERT INTO core.users (username, email, password_hash)
VALUES ($1, $2, 'x') RETURNING id::text
`, r.username, r.email).Scan(&r.userID))
// Системна роль, а не роль кабінету: у 0010 системні ролі заведені
// з tenant_id IS NULL і спільні для всіх кабінетів. Яка саме —
// байдуже: тут перевіряється членство, а не права.
var roleID string
must("роль", pool.QueryRow(ctx, `
SELECT id::text FROM core.roles WHERE tenant_id IS NULL AND key = 'viewer'
`).Scan(&roleID))
_, err := pool.Exec(ctx, `
INSERT INTO core.memberships (tenant_id, user_id, role_id) VALUES ($1, $2, $3)
`, r.tenantID, r.userID, roleID)
must("членство", err)
return r
}
func logins(p LoginAttemptsPage) []string {
out := make([]string, 0, len(p.Attempts))
for _, a := range p.Attempts {
out = append(out, a.Login)
}
return out
}

View file

@ -552,9 +552,64 @@ func (s *Store) CreateUser(ctx context.Context, tenantID, username, email, passw
return &u, nil return &u, nil
} }
// MemberRef — учасник разом із роллю, яку він мав у цю мить.
//
// Потрібен журналу аудиту, і саме тому в ньому і ключ ролі, і назва.
// Ключ — те, за чим шукають і що не змінюється; назва — те, що людина
// впізнає, і те, чого через рік може вже не існувати: роль
// перейменують або зітруть, і рядок «роль 7f3a…» перестане відповідати
// на питання, заради якого журнал відкрили.
type MemberRef struct {
UserID string
Username string
RoleID string
RoleKey string
RoleName string
}
// SetRole змінює роль учасника команди. // SetRole змінює роль учасника команди.
func (s *Store) SetRole(ctx context.Context, tenantID, userID, roleID string) error { //
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { // Повертає стан ДО і ПІСЛЯ — так само, як 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, ` tag, err := tx.Exec(ctx, `
UPDATE core.memberships SET role_id = $3 UPDATE core.memberships SET role_id = $3
WHERE tenant_id = $1 AND user_id = $2 WHERE tenant_id = $1 AND user_id = $2
@ -567,6 +622,7 @@ func (s *Store) SetRole(ctx context.Context, tenantID, userID, roleID string) er
} }
return nil return nil
}) })
return before, after, err
} }
// RemoveMember прибирає людину з тенанта. // RemoveMember прибирає людину з тенанта.
@ -574,8 +630,28 @@ func (s *Store) SetRole(ctx context.Context, tenantID, userID, roleID string) er
// Сам користувач лишається: він може працювати в іншому тенанті, а його // Сам користувач лишається: він може працювати в іншому тенанті, а його
// сліди в аудиті мають лишатися читабельними. Зате всі сесії саме в // сліди в аудиті мають лишатися читабельними. Зате всі сесії саме в
// цьому тенанті відкликаються негайно. // цьому тенанті відкликаються негайно.
func (s *Store) RemoveMember(ctx context.Context, tenantID, userID string) error { func (s *Store) RemoveMember(ctx context.Context, tenantID, userID string) (MemberRef, error) {
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) 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, ` tag, err := tx.Exec(ctx, `
DELETE FROM core.memberships WHERE tenant_id = $1 AND user_id = $2 DELETE FROM core.memberships WHERE tenant_id = $1 AND user_id = $2
`, tenantID, userID) `, tenantID, userID)
@ -592,6 +668,7 @@ func (s *Store) RemoveMember(ctx context.Context, tenantID, userID string) error
`, userID, tenantID) `, userID, tenantID)
return err return err
}) })
return gone, err
} }
// ChangePassword міняє пароль і відкликає всі інші сесії. // ChangePassword міняє пароль і відкликає всі інші сесії.
@ -649,8 +726,13 @@ type UserProfileInput struct {
// тенанту. Інакше адмін філії міг би змінити пароль людині, яка тим // тенанту. Інакше адмін філії міг би змінити пароль людині, яка тим
// самим акаунтом заходить у сусідню організацію, — і та про це навіть // самим акаунтом заходить у сусідню організацію, — і та про це навіть
// не дізналася б. // не дізналася б.
func (s *Store) UpdateUserProfile(ctx context.Context, tenantID, userID string, in UserProfileInput) error { // Повертає логін, який має обліковий запис ПІСЛЯ правки. Потрібен
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { // журналу: «змінили профіль користувачу 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 var member bool
if err := tx.QueryRow(ctx, ` if err := tx.QueryRow(ctx, `
SELECT EXISTS (SELECT 1 FROM core.memberships SELECT EXISTS (SELECT 1 FROM core.memberships
@ -681,7 +763,7 @@ func (s *Store) UpdateUserProfile(ctx context.Context, tenantID, userID string,
hash = &h hash = &h
} }
if _, err := tx.Exec(ctx, ` if err := tx.QueryRow(ctx, `
UPDATE core.users UPDATE core.users
SET username = COALESCE(NULLIF($2,'')::citext, username), SET username = COALESCE(NULLIF($2,'')::citext, username),
email = CASE WHEN $3 = '' THEN email email = CASE WHEN $3 = '' THEN email
@ -691,7 +773,8 @@ func (s *Store) UpdateUserProfile(ctx context.Context, tenantID, userID string,
password_hash = COALESCE($5, password_hash), password_hash = COALESCE($5, password_hash),
updated_at = now() updated_at = now()
WHERE id = $1 WHERE id = $1
`, userID, in.Username, in.Email, in.FullName, hash); err != nil { RETURNING username::text
`, userID, in.Username, in.Email, in.FullName, hash).Scan(&username); err != nil {
return fmt.Errorf("оновлення профілю: %w", err) return fmt.Errorf("оновлення профілю: %w", err)
} }
@ -708,6 +791,7 @@ func (s *Store) UpdateUserProfile(ctx context.Context, tenantID, userID string,
} }
return nil return nil
}) })
return username, err
} }
// UserByID читає обліковий запис для сторінки профілю та /me. // UserByID читає обліковий запис для сторінки профілю та /me.

View file

@ -27,6 +27,7 @@ import { QueuesPage } from './pages/QueuesPage'
import { TeamPage } from './pages/TeamPage' import { TeamPage } from './pages/TeamPage'
import { RolesPage } from './pages/RolesPage' import { RolesPage } from './pages/RolesPage'
import { AuditPage } from './pages/AuditPage' import { AuditPage } from './pages/AuditPage'
import { LoginsPage } from './pages/LoginsPage'
import { TrapsPage } from './pages/TrapsPage' import { TrapsPage } from './pages/TrapsPage'
import { SLAPage } from './pages/SLAPage' import { SLAPage } from './pages/SLAPage'
import { StoragePage } from './pages/StoragePage' import { StoragePage } from './pages/StoragePage'
@ -156,6 +157,12 @@ function Workspace() {
сховища й дзеркала. */} сховища й дзеркала. */}
<Route path="/billing" element={<Guard perm="billing:read" element={<BillingPage />} />} /> <Route path="/billing" element={<Guard perm="billing:read" element={<BillingPage />} />} />
<Route path="/audit" element={<Guard perm="audit:read" element={<AuditPage />} />} /> <Route path="/audit" element={<Guard perm="audit:read" element={<AuditPage />} />} />
{/* Історія входів під тим самим правом, що й журнал: обидві
сторінки про те, «хто і що робив з нашим доступом», і
розслідування це поєднання їхніх рядків. Роздати їх
різним людям означало б, що один бачить «видалив конфіги о
14:02», а другий «увійшов о 14:01 з чужої адреси». */}
<Route path="/logins" element={<Guard perm="audit:read" element={<LoginsPage />} />} />
{/* Журнал сервера під settings:write, а НЕ під audit:read і це {/* Журнал сервера під settings:write, а НЕ під audit:read і це
протилежне рішення до сховища й дзеркала навмисно. Там ішлося протилежне рішення до сховища й дзеркала навмисно. Там ішлося
про стан мережі, який видно кожному, хто бачить моніторинг; про стан мережі, який видно кожному, хто бачить моніторинг;

View file

@ -81,6 +81,9 @@ import type {
AuditPage, AuditPage,
AuditEventDetail, AuditEventDetail,
AuditMeta, AuditMeta,
LoginsFilter,
LoginAttemptsPage,
LoginsMeta,
GitMirror, GitMirror,
MirrorTest, MirrorTest,
SelfDeviceRow, SelfDeviceRow,
@ -1537,6 +1540,34 @@ export const api = {
/** Довідники дій та акторів, межі журналу й перелік його сліпих зон. */ /** Довідники дій та акторів, межі журналу й перелік його сліпих зон. */
auditMeta: () => request<AuditMeta>('/api/v1/audit/meta'), auditMeta: () => request<AuditMeta>('/api/v1/audit/meta'),
// --- історія входів ---
/**
* Сторінка історії входів.
*
* Влаштована рівно як `audit()` вище, і навмисно: це та сама пара
* питань («хто заходив» / «хто що зробив»), і посилання на звужений
* перелік так само пересилають колезі. Що саме сюди НЕ потрапляє
* не деталь реалізації, а частина відповіді: сервер віддає перелік
* меж у `loginsMeta().blind_spots`, і сторінка мусить його показати.
*/
logins: (f: LoginsFilter = {}) => {
const p = new URLSearchParams()
if (f.from) p.set('from', f.from)
if (f.to) p.set('to', f.to)
if (f.outcome) p.set('outcome', f.outcome)
for (const r of f.reasons ?? []) p.append('reason', r)
for (const u of f.users ?? []) p.append('user', u)
if (f.ip) p.set('ip', f.ip)
if (f.q) p.set('q', f.q)
if (f.limit) p.set('limit', String(f.limit))
if (f.cursor) p.set('cursor', f.cursor)
return request<LoginAttemptsPage>(`/api/v1/logins?${p.toString()}`)
},
/** Словник причин, перелік людей, межі й перелік того, чого не видно. */
loginsMeta: () => request<LoginsMeta>('/api/v1/logins/meta'),
// ------------------------------------------------------------------- // -------------------------------------------------------------------
// SNMP-трапи // SNMP-трапи
// ------------------------------------------------------------------- // -------------------------------------------------------------------

View file

@ -183,6 +183,13 @@ const navGroups: NavGroup[] = [
// пункт не з'явиться — а разом із ним і група, якщо в ній більше // пункт не з'явиться — а разом із ним і група, якщо в ній більше
// нічого не лишилось. // нічого не лишилось.
{ to: '/audit', label: 'Журнал аудиту', icon: '🧾', perm: 'audit:read' }, { to: '/audit', label: 'Журнал аудиту', icon: '🧾', perm: 'audit:read' },
// Одразу під журналом і під тим самим правом: це його друга
// половина. Журнал відповідає на «хто що зробив УЖЕ ВСЕРЕДИНІ», а
// на «хто сюди заходив» він не відповідає взагалі — спроби входу
// в нього не пишуться (кабінет на момент перевірки пароля ще
// невідомий). Порізно людина шукала б невдалі спроби в журналі й
// не знайшла б їх ніколи.
{ to: '/logins', label: 'Історія входів', icon: '🔑', perm: 'audit:read' },
// Одразу під журналом аудиту, бо їх плутають, а різниця між ними // Одразу під журналом аудиту, бо їх плутають, а різниця між ними
// вирішальна: аудит відповідає на «хто що зробив», журнал сервера — // вирішальна: аудит відповідає на «хто що зробив», журнал сервера —
// на «на що лається сама система». Поруч цю різницю видно з двох // на «на що лається сама система». Поруч цю різницю видно з двох

View file

@ -0,0 +1,756 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { api } from '../api/client'
import { ChipRow } from '../components/DeviceFilterPanel'
import {
Button,
Card,
ErrorNote,
PageBody,
PageHeader,
Spinner,
inputClass,
plural,
} from '../components/ui'
import type { LoginAttempt, LoginAttemptsPage, LoginsMeta } from '../types'
/**
* «Історія входів»: хто заходив, звідки й що не вийшло.
*
* Рідна сестра «Журналу аудиту», і навмисно: той самий курсор замість
* номерів сторінок, той самий закріплений період, той самий блок про
* межі показаного внизу. Різні вони питанням: аудит відповідає на «хто
* що зробив УЖЕ ВСЕРЕДИНІ», ця сторінка на «хто і як сюди
* потрапляв». Друге питання в журнал аудиту не потрапляє взагалі
* спроби входу лягають в окрему таблицю, бо на момент перевірки пароля
* кабінет ще невідомий.
*
* Чотири рішення, які видно на екрані.
*
* 1. **Невдала спроба помітна без жодного фільтра.** Заради неї
* сторінку й відкривають, тож вона червона, а причина відмови стоїть
* у самому рядку, а не під розкриттям. Угорі рахунок «стільки
* невдалих зі стількох»: щоб відповідь на «нас ламають?» була видна
* раніше, ніж людина дочитає перший екран.
*
* 2. **Логін показаний окремо від людини.** У рядку лежить те, що
* НАСПРАВДІ ввели в поле входу, і саме одруківка («adm1n») або
* чужий формат («root») і є сигналом. Поруч до кого спроба
* прив'язалась, під поточним іменем цієї людини.
*
* 3. **Пристрій здогадка, тому вона поруч із першоджерелом.**
* User-Agent задає клієнт, і вгадане «Chrome» нічого не варте в
* розборі інциденту. Переказ у рядку, повний рядок під розкриттям.
*
* 4. **Межа видимості на сторінці, а не в коді.** Ця сторінка
* показує НЕ ВСЕ, і мовчати про це не можна: у core.login_attempts
* немає tenant_id, тож спроба потрапляє сюди лише тоді, коли її
* вдалося прив'язати до людини з вашого кабінету. Скільки невдалих
* спроб прив'язати не вдалося окремим блоком угорі; чого сторінка
* не бачить узагалі переліком унизу (його складає сервер, поруч із
* самою вибіркою).
*
* Стан фільтра живе в адресі з тієї ж причини, що в журналі:
* звужений перелік пересилають колезі, і посилання має нести питання
* цілком.
*/
/** Готові періоди. Типовий тиждень, а не місяць, як у журналі:
* сюди приходять із питанням «що відбувається зараз». */
const PERIODS: { key: string; label: string; hours: number }[] = [
{ key: '24h', label: '24 години', hours: 24 },
{ key: '7d', label: '7 днів', hours: 24 * 7 },
{ key: '30d', label: '30 днів', hours: 24 * 30 },
{ key: '90d', label: '90 днів', hours: 24 * 90 },
{ key: '365d', label: 'рік', hours: 24 * 365 },
]
const DEFAULT_PERIOD = '7d'
const PAGE_SIZE = 50
const OUTCOMES: { value: string; label: string }[] = [
{ value: 'failed', label: 'лише невдалі' },
{ value: 'success', label: 'лише вдалі' },
]
interface Filter {
period: string
/** Заповнені лише коли period === 'custom'. */
from: string
to: string
/** '' — усе. */
outcome: string
reasons: string[]
users: string[]
ip: string
q: string
}
function parseFilter(sp: URLSearchParams): Filter {
return {
period: sp.get('period') || DEFAULT_PERIOD,
from: sp.get('from') || '',
to: sp.get('to') || '',
outcome: sp.get('outcome') || '',
reasons: sp.getAll('reason'),
users: sp.getAll('user'),
ip: sp.get('ip') || '',
q: sp.get('q') || '',
}
}
function writeFilter(f: Filter): URLSearchParams {
const sp = new URLSearchParams()
if (f.period !== DEFAULT_PERIOD) sp.set('period', f.period)
if (f.period === 'custom') {
if (f.from) sp.set('from', f.from)
if (f.to) sp.set('to', f.to)
}
if (f.outcome) sp.set('outcome', f.outcome)
for (const r of f.reasons) sp.append('reason', r)
for (const u of f.users) sp.append('user', u)
if (f.ip) sp.set('ip', f.ip)
if (f.q) sp.set('q', f.q)
return sp
}
/** Межі періоду в ISO для запиту. */
function periodRange(f: Filter): { from?: string; to?: string } {
if (f.period === 'custom') {
return {
from: f.from ? new Date(f.from).toISOString() : undefined,
to: f.to ? new Date(f.to).toISOString() : undefined,
}
}
const p = PERIODS.find((x) => x.key === f.period) ?? PERIODS[1]
const to = new Date()
return { from: new Date(to.getTime() - p.hours * 3600_000).toISOString(), to: to.toISOString() }
}
const EMPTY: Partial<Filter> = { outcome: '', reasons: [], users: [], ip: '', q: '' }
export function LoginsPage() {
const [sp, setSp] = useSearchParams()
// Розбираємо саме РЯДОК запиту: так `filter` міняє тотожність лише
// тоді, коли міняється питання.
const search = sp.toString()
const filter = useMemo(() => parseFilter(new URLSearchParams(search)), [search])
const [meta, setMeta] = useState<LoginsMeta | null>(null)
const [page, setPage] = useState<LoginAttemptsPage | null>(null)
const [attempts, setAttempts] = useState<LoginAttempt[]>([])
const [cursor, setCursor] = useState<string | undefined>()
/** Період, у якому сервер зібрав першу сторінку. Дальші беруться в ньому ж. */
const [range, setRange] = useState<{ from: string; to: string } | null>(null)
const [loading, setLoading] = useState(true)
const [more, setMore] = useState(false)
const [error, setError] = useState<string | null>(null)
const patch = useCallback(
(p: Partial<Filter>) => setSp(writeFilter({ ...filter, ...p }), { replace: true }),
[filter, setSp],
)
useEffect(() => {
api
.loginsMeta()
.then(setMeta)
.catch((e) => setError(e instanceof Error ? e.message : String(e)))
}, [])
useEffect(() => {
let alive = true
setLoading(true)
setError(null)
api
.logins({
...periodRange(filter),
outcome: filter.outcome || undefined,
reasons: filter.reasons,
users: filter.users,
ip: filter.ip || undefined,
q: filter.q || undefined,
limit: PAGE_SIZE,
})
.then((p) => {
if (!alive) return
setPage(p)
setAttempts(p.attempts)
setCursor(p.next_cursor)
setRange({ from: p.from, to: p.to })
})
.catch((e) => alive && setError(e instanceof Error ? e.message : String(e)))
.finally(() => alive && setLoading(false))
return () => {
alive = false
}
}, [filter])
const loadMore = useCallback(() => {
if (!cursor || !range) return
setMore(true)
api
.logins({
from: range.from,
to: range.to,
outcome: filter.outcome || undefined,
reasons: filter.reasons,
users: filter.users,
ip: filter.ip || undefined,
q: filter.q || undefined,
limit: PAGE_SIZE,
cursor,
})
.then((p) => {
setAttempts((prev) => [...prev, ...p.attempts])
setCursor(p.next_cursor)
})
.catch((e) => setError(e instanceof Error ? e.message : String(e)))
.finally(() => setMore(false))
}, [cursor, range, filter])
const active =
filter.reasons.length +
filter.users.length +
(filter.outcome ? 1 : 0) +
(filter.ip ? 1 : 0) +
(filter.q ? 1 : 0)
return (
<>
<PageHeader
title="Історія входів"
subtitle={
meta?.bounds.first
? `записи з ${fmtDate(meta.bounds.first)} · час місцевий`
: 'час місцевий'
}
actions={
active > 0 ? (
<Button onClick={() => setSp(writeFilter({ ...filter, ...EMPTY }), { replace: true })}>
Скинути фільтри ({active})
</Button>
) : undefined
}
/>
<PageBody className="space-y-3">
<ErrorNote>{error}</ErrorNote>
<Tally attempts={attempts} loading={loading} filter={filter} onChange={patch} />
<Unattributed page={page} />
<Filters meta={meta} filter={filter} onChange={patch} />
{loading ? (
<Spinner />
) : attempts.length === 0 ? (
<EmptyNote meta={meta} filter={filter} range={range} />
) : (
<Card className="divide-y divide-slate-800/70">
{attempts.map((a, i) => (
<AttemptRow
key={a.id}
attempt={a}
showDate={i === 0 || dayKey(attempts[i - 1].ts) !== dayKey(a.ts)}
/>
))}
</Card>
)}
{cursor && !loading && (
<div className="flex justify-center">
<Button onClick={loadMore} disabled={more}>
{more ? 'Завантаження…' : 'Показати ще'}
</Button>
</div>
)}
{!cursor && attempts.length > 0 && !loading && (
<p className="text-center text-xs text-slate-600">
це всі спроби за обраний період {attempts.length}{' '}
{plural(attempts.length, ['спроба', 'спроби', 'спроб'])}
</p>
)}
<BlindSpots meta={meta} />
</PageBody>
</>
)
}
// ---------------------------------------------------------------------
// Рахунок угорі
// ---------------------------------------------------------------------
/**
* Скільки з показаного невдалі.
*
* Стоїть ПЕРЕД фільтрами, бо це і є відповідь, по яку сюди приходять, а
* гортати перелік у пошуках червоного робота, якої можна не робити.
* Число клікабельне: воно ж і фільтр «лише невдалі».
*
* Рахунок ЗАВЖДИ про показане, а не про період: коли натиснули
* «показати ще», у переліку більше рядків, і сказати «12 невдалих»,
* маючи на увазі перші п'ятдесят, означало б назвати число, яке нічому
* на екрані не відповідає.
*/
function Tally({
attempts,
loading,
filter,
onChange,
}: {
attempts: LoginAttempt[]
loading: boolean
filter: Filter
onChange: (p: Partial<Filter>) => void
}) {
if (loading || attempts.length === 0) return null
const failed = attempts.filter((a) => !a.success).length
const only = filter.outcome === 'failed'
return (
<Card className="flex flex-wrap items-baseline gap-x-3 gap-y-1 p-3 text-sm">
<span className={failed > 0 ? 'font-medium text-red-200' : 'text-slate-300'}>
{failed} {plural(failed, ['невдала спроба', 'невдалі спроби', 'невдалих спроб'])}
</span>
<span className="text-xs text-slate-500">
із {attempts.length} показаних {plural(attempts.length, ['спроби', 'спроб', 'спроб'])}
</span>
{failed > 0 && (
<button
className="ml-auto rounded border border-slate-700 px-2 py-0.5 text-[11px] text-slate-400 hover:text-slate-200"
onClick={() => onChange({ outcome: only ? '' : 'failed' })}
>
{only ? 'показати всі' : 'лише невдалі'}
</button>
)}
</Card>
)
}
/**
* Межа видимості, яку не можна замовчати.
*
* Спроба потрапляє на цю сторінку лише тоді, коли її вдалося прив'язати
* до людини з вашого кабінету через логін, пошту або ідентифікатор.
* Невдалу спробу з логіном, якого в системі немає, прив'язати нема до
* чого: вона не належить жодному кабінету, і показати її комусь
* означало б віддати чужі дані. Тому рядків тут немає є число.
*
* Без цього блоку сторінка тихо бреше: хвиля перебору неіснуючих
* логінів виглядала б на ній як спокійний день.
*/
function Unattributed({ page }: { page: LoginAttemptsPage | null }) {
const u = page?.unattributed
if (!u || u.attempts === 0) return null
return (
<Card className="border-amber-900/60 bg-amber-950/20 p-3">
<div className="text-xs font-medium text-amber-200">
Ще {u.attempts} {plural(u.attempts, ['невдала спроба', 'невдалі спроби', 'невдалих спроб'])} за
цей період не показані нижче
</div>
<p className="mt-1 text-[11px] leading-relaxed text-amber-100/70">
Це спроби входу з логінами, яких у системі немає взагалі з{' '}
{u.ips} {plural(u.ips, ['адреси', 'адрес', 'адрес'])}. Прив'язати їх до кабінету нема до
чого, а показати рядками означало б віддати комусь чужі дані, тож видно лише рахунок.
Багато таких спроб з кількох адрес це перебір логінів; одна-дві найчастіше людина,
яка помилилась у власному логіні.
</p>
</Card>
)
}
// ---------------------------------------------------------------------
// Фільтри
// ---------------------------------------------------------------------
function Filters({
meta,
filter,
onChange,
}: {
meta: LoginsMeta | null
filter: Filter
onChange: (p: Partial<Filter>) => void
}) {
// Пошук набирають по літері, а кожна літера — це запит до бази.
// Локальний стан плюс одна пауза на обидва поля: двома таймерами
// набране в першому губилось би, щойно перейшли до другого.
const [q, setQ] = useState(filter.q)
const [ip, setIP] = useState(filter.ip)
useEffect(() => setQ(filter.q), [filter.q])
useEffect(() => setIP(filter.ip), [filter.ip])
useEffect(() => {
if (q === filter.q && ip === filter.ip) return
const t = setTimeout(() => onChange({ q, ip }), 400)
return () => clearTimeout(t)
}, [q, ip, filter.q, filter.ip, onChange])
const reasonOptions = (meta?.reasons ?? []).map((r) => ({ value: r.key, label: r.label }))
const userOptions = (meta?.actors ?? []).map((a) => ({ value: a.id, label: a.label }))
return (
<Card className="space-y-2.5 p-3">
<div className="flex flex-wrap items-end gap-2">
<label className="min-w-[14rem] flex-1">
<span className="mb-1 block text-[11px] text-slate-500">Введений логін</span>
<input
className={inputClass}
value={q}
placeholder="admin, root, пошта…"
onChange={(e) => setQ(e.target.value)}
/>
</label>
<label className="w-40">
<span className="mb-1 block text-[11px] text-slate-500">Адреса або підмережа</span>
<input
className={inputClass}
value={ip}
placeholder="10.20.0.0/24"
onChange={(e) => setIP(e.target.value)}
/>
</label>
<PeriodRow filter={filter} maxDays={meta?.max_range_days} onChange={onChange} />
</div>
{/* Результат один із трьох станів, тому не ChipRow: два обраних
«лише невдалі» і «лише вдалі» разом означали б «нічого». */}
<div className="flex flex-wrap items-center gap-1">
<span className="w-20 shrink-0 text-[11px] text-slate-500">Результат</span>
{OUTCOMES.map((o) => {
const on = filter.outcome === o.value
return (
<button
key={o.value}
className={`rounded border px-1.5 py-0.5 text-[11px] transition-colors ${
on
? 'border-sky-700 bg-sky-900/50 text-sky-200'
: 'border-slate-700 bg-slate-800 text-slate-400 hover:text-slate-200'
}`}
onClick={() => onChange({ outcome: on ? '' : o.value })}
>
{o.label}
</button>
)
})}
</div>
<ChipRow
title="Причина"
options={reasonOptions}
selected={filter.reasons}
onChange={(v) => onChange({ reasons: v })}
/>
<ChipRow
title="Хто"
options={userOptions}
selected={filter.users}
onChange={(v) => onChange({ users: v })}
/>
</Card>
)
}
/**
* Період. Стеля та сама, що в журналі аудиту, і причина та сама:
* глибше дані в базі стиснуто. Практичного значення тут вона майже не
* має спроби входу типово живуть 180 днів.
*/
function PeriodRow({
filter,
maxDays,
onChange,
}: {
filter: Filter
maxDays?: number
onChange: (p: Partial<Filter>) => void
}) {
return (
<div className="flex flex-wrap items-end gap-2">
<label>
<span className="mb-1 block text-[11px] text-slate-500">Період</span>
<select
className={inputClass}
value={filter.period}
onChange={(e) => onChange({ period: e.target.value })}
title={
maxDays
? `глибина обмежена ${maxDays} ${plural(maxDays, ['днем', 'днями', 'днями'])}`
: undefined
}
>
{PERIODS.map((p) => (
<option key={p.key} value={p.key}>
останні {p.label}
</option>
))}
<option value="custom">свій</option>
</select>
</label>
{filter.period === 'custom' && (
<>
<label>
<span className="mb-1 block text-[11px] text-slate-500">від</span>
<input
type="datetime-local"
className={inputClass}
value={filter.from}
onChange={(e) => onChange({ from: e.target.value })}
/>
</label>
<label>
<span className="mb-1 block text-[11px] text-slate-500">до</span>
<input
type="datetime-local"
className={inputClass}
value={filter.to}
onChange={(e) => onChange({ to: e.target.value })}
/>
</label>
</>
)}
</div>
)
}
// ---------------------------------------------------------------------
// Рядок
// ---------------------------------------------------------------------
function AttemptRow({ attempt, showDate }: { attempt: LoginAttempt; showDate: boolean }) {
const [open, setOpen] = useState(false)
const a = attempt
// Причина показана фразою, але ключ лишається під розкриттям — те
// саме рішення, що з діями в журналі аудиту: фразу читають люди,
// ключем шукають у логах сервера. Назви немає — показуємо ключ як є:
// вигадана з підкреслень фраза виглядала б назвою й брехала б.
const reason = a.reason_label || a.reason
return (
<div>
{showDate && (
<div className="bg-slate-900/70 px-4 py-1 text-[11px] font-medium text-slate-500">
{fmtDate(a.ts)}
</div>
)}
<button
className="flex w-full flex-col gap-1 px-4 py-2.5 text-left hover:bg-slate-800/40"
onClick={() => setOpen((v) => !v)}
>
<div className="flex w-full flex-wrap items-baseline gap-x-3 gap-y-1">
<time className="w-16 shrink-0 tabular-nums text-xs text-slate-500">{fmtTime(a.ts)}</time>
<span
className={`rounded border px-1.5 text-[10px] ${
a.success
? 'border-emerald-900 bg-emerald-950/50 text-emerald-300'
: 'border-red-900 bg-red-950/50 text-red-300'
}`}
>
{a.success ? 'вхід' : 'невдало'}
</span>
{/* Введений логін головне в рядку. Саме одруківка в ньому
(«adm1n») і є сигналом, і ховати його за іменем людини
означало б прибрати те, заради чого дивляться. */}
<span
className={`font-mono text-sm ${a.success ? 'text-slate-100' : 'text-red-200'}`}
title="що ввели в поле логіна"
>
{a.login}
</span>
{!a.success && reason && <span className="text-xs text-red-300/80">{reason}</span>}
<span className="ml-auto flex items-baseline gap-2 text-xs">
{a.ip && <span className="tabular-nums text-slate-400">{a.ip}</span>}
<span className="w-3 shrink-0 text-slate-600">{open ? '▾' : '▸'}</span>
</span>
</div>
<div className="flex w-full flex-wrap items-baseline gap-x-3 pl-[4.75rem] text-xs text-slate-500">
{/* Кому спроба належить під поточним іменем людини: саме за
ним її впізнають сьогодні, навіть якщо тоді вводили інше. */}
<span className="text-slate-400">{a.username || '—'}</span>
{a.full_name && <span>{a.full_name}</span>}
{/* Пристрій — здогадка; повний User-Agent під розкриттям. */}
{a.device ? (
<span>{a.device}</span>
) : (
a.user_agent && <span className="min-w-0 truncate">{a.user_agent}</span>
)}
</div>
</button>
{open && (
<div className="border-t border-slate-800/70 bg-slate-950/40 px-4 py-3">
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-xs">
<dt className="text-slate-500">Коли</dt>
<dd className="text-slate-300">{fmtFull(a.ts)}</dd>
<dt className="text-slate-500">Введено</dt>
<dd className="font-mono text-slate-300">{a.login}</dd>
<dt className="text-slate-500">Хто</dt>
<dd className="text-slate-300">
{a.username || 'невідомо'}
{a.email ? ` · ${a.email}` : ''}
{a.full_name ? ` · ${a.full_name}` : ''}
</dd>
<dt className="text-slate-500">Результат</dt>
<dd className={a.success ? 'text-emerald-300' : 'text-red-300'}>
{a.success ? 'вхід виконано' : 'відмовлено'}
{!a.success && a.reason ? (
<>
{reason ? ` · ${reason}` : ''}{' '}
<code className="text-[11px] text-slate-600">{a.reason}</code>
</>
) : null}
</dd>
<dt className="text-slate-500">Звідки</dt>
<dd className="font-mono text-slate-300">{a.ip || '—'}</dd>
<dt className="text-slate-500">Пристрій</dt>
<dd className="text-slate-300">
{/* Здогадка й першоджерело поруч, ніколи одне замість
іншого: User-Agent задає клієнт, і в розборі інциденту
вгадане «Chrome» нічого не доводить. */}
{a.device && <span>{a.device}</span>}
{a.user_agent ? (
<code className="mt-0.5 block break-all text-[11px] text-slate-500">
{a.user_agent}
</code>
) : (
!a.device && <span className="text-slate-500">не передано</span>
)}
</dd>
<dt className="text-slate-500">Запис</dt>
<dd className="font-mono text-slate-500">{a.id}</dd>
</dl>
</div>
)}
</div>
)
}
// ---------------------------------------------------------------------
// Порожній результат і межі
// ---------------------------------------------------------------------
/**
* Порожній перелік найнебезпечніше місце сторінки.
*
* «Нічого не знайдено» тут читається як «ніхто не ламався», хоча
* означати може чотири різні речі: у цьому кабінеті ще жодного входу;
* період закінчився раніше, ніж почалась історія; фільтр надто вузький;
* або спроби були, але жодну не вдалося прив'язати до вашого кабінету
* і тоді відповідь стоїть у блоці вище, а не тут.
*/
function EmptyNote({
meta,
filter,
range,
}: {
meta: LoginsMeta | null
filter: Filter
range: { from: string; to: string } | null
}) {
const first = meta?.bounds.first
const narrowed =
filter.reasons.length + filter.users.length > 0 || !!filter.ip || !!filter.q || !!filter.outcome
let text: string
if (!first) {
text =
'У цьому кабінеті ще немає жодної спроби входу, яку вдалося б до нього прив’язати. Це не означає, що входів не було: спроба потрапляє сюди лише тоді, коли логін або пошта збіглися з учасником кабінету — перелік меж нижче.'
} else if (range && new Date(range.to) < new Date(first)) {
text = `Обраний період закінчився раніше, ніж почалась історія: перша спроба — ${fmtDate(first)}. За цей час записів немає й бути не могло.`
} else if (narrowed) {
text =
'За обраними умовами спроб немає. Спробуйте розширити період або зняти частину умов — і зважте на перелік того, чого сторінка не бачить узагалі.'
} else {
text = `За обраний період спроб входу немає. Історія ведеться з ${fmtDate(first)}.`
}
return (
<Card className="px-4 py-8">
<p className="mx-auto max-w-2xl text-center text-sm leading-relaxed text-slate-400">{text}</p>
</Card>
)
}
/**
* Чого ця сторінка не бачить.
*
* Той самий блок і та сама причина, що в журналі аудиту: найгірша
* можлива помилка сторінки про відсутність мовчазна порожнеча на
* місці невиміряного. Тут вона дорожча вдвічі, бо сюди приходять із
* питанням «нас ламають?».
*
* Перелік складає сервер (store.LoginBlindSpots) там же, де сама
* вибірка: кожен рядок у ньому є наслідком того, як влаштоване
* зіставлення спроби з кабінетом, і копія в браузері розійшлася б із
* ним на першій же правці.
*/
function BlindSpots({ meta }: { meta: LoginsMeta | null }) {
const items = meta?.blind_spots ?? []
if (items.length === 0) return null
return (
<Card className="p-3">
<div className="text-xs font-medium text-slate-400">Чого ця сторінка не бачить</div>
<p className="mt-1 text-[11px] leading-relaxed text-slate-600">
У таблиці спроб входу немає ознаки кабінету рядок пишеться тоді, коли кабінет ще
невідомий. Тому сюди потрапляє лише те, що вдалося прив'язати до учасника ЦЬОГО кабінету, і
порожній результат означає «не прив'язалось», а не «не було».
</p>
<ul className="mt-2 space-y-1.5 text-xs leading-relaxed text-slate-500">
{items.map((s) => (
<li key={s} className="flex gap-2">
<span className="text-slate-700"></span>
<span>{s}</span>
</li>
))}
</ul>
</Card>
)
}
// ---------------------------------------------------------------------
// Дрібниці
// ---------------------------------------------------------------------
function dayKey(iso: string): string {
const d = new Date(iso)
return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`
}
function fmtDate(iso: string): string {
return new Date(iso).toLocaleDateString('uk-UA', {
day: '2-digit',
month: 'long',
year: 'numeric',
})
}
function fmtTime(iso: string): string {
return new Date(iso).toLocaleTimeString('uk-UA', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
}
function fmtFull(iso: string): string {
return `${fmtDate(iso)} ${fmtTime(iso)}`
}

View file

@ -0,0 +1,287 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'
import { LoginsPage } from '../pages/LoginsPage'
import { fetchRouter, type Call } from './support'
/**
* Сторінка «Історія входів».
*
* ЧОМУ САМЕ ЦІ ПЕРЕВІРКИ. Сторінку відкривають з одним питанням: «чи
* нас ламають». Помилка тут не падає й не порожнить екран вона тихо
* дає ЗАСПОКІЙЛИВУ відповідь: перелік намальовано, рядки на місці, і
* людина йде далі. Тому перевіряється рівно те, що робить відповідь
* чесною:
*
* 1. Невдала спроба видна ОДРАЗУ без фільтрів, без розкриття рядка:
* з логіном, причиною відмови й адресою. Заради неї сторінка й
* потрібна.
* 2. Межа показаного названа вголос. Спроби, які не вдалося прив'язати
* до кабінету, рядками не показуються НІКОМУ і якщо блок з їх
* рахунком зникне, хвиля перебору неіснуючих логінів виглядатиме
* на сторінці як спокійний день. Це найдорожча можлива поломка
* файла, і саме тому вона тут перевіряється.
* 3. Гортання йде КУРСОРОМ у закріпленому періоді. Не «наступна
* сторінка»: вхід, що стався між двома запитами, зсунув би вибірку
* на рядок, і один запис або зник би, або показався двічі.
*
* Набір параметрів звірений із `handleListLoginAttempts`
* (`server/internal/httpapi/logins.go`): outcome, reason, user, ip, q,
* from, to, limit, cursor.
*/
const meta = {
reasons: [
{ key: 'bad_password', label: 'Невірний пароль' },
{ key: 'no_user', label: 'Такого логіна немає' },
],
actors: [{ id: 'u-1', label: 'admin' }],
bounds: { first: '2026-06-01T00:00:00Z', last: '2026-08-27T00:00:00Z' },
blind_spots: [
'Спроби входу людей з інших кабінетів.',
'Спроби, відбиті гальмуванням перебору.',
],
max_range_days: 366,
}
function attempt(over: Record<string, unknown> = {}) {
return {
id: 'a-1',
ts: '2026-08-27T10:00:00Z',
login: 'admin',
success: true,
ip: '10.20.0.5',
user_agent: 'curl/8.4.0',
device: 'curl (скрипт)',
user_id: 'u-1',
username: 'admin',
email: 'admin@example.test',
...over,
}
}
function page(attempts: unknown[], over: Record<string, unknown> = {}) {
return {
from: '2026-08-20T00:00:00Z',
to: '2026-08-27T12:00:00Z',
attempts,
unattributed: { attempts: 0, ips: 0 },
...over,
}
}
/** Остання адреса, на якій опинилася сторінка. */
let lastSearch = ''
function Spy() {
lastSearch = useLocation().search
return null
}
function open(url: string, routes: Record<string, unknown> = {}) {
const server = fetchRouter({
'GET /api/v1/logins/meta': meta,
'GET /api/v1/logins': page([]),
...routes,
})
render(
<MemoryRouter initialEntries={[url]}>
<Routes>
<Route
path="/logins"
element={
<>
<Spy />
<LoginsPage />
</>
}
/>
</Routes>
</MemoryRouter>,
)
return server
}
/** Питання до сервера без часових меж: `from`/`to` рахуються від
* Date.now() і в двох прогонах збігтись не можуть. */
function question(call: Call): Record<string, string[]> {
const sp = new URLSearchParams(call.url.split('?')[1] ?? '')
const out: Record<string, string[]> = {}
for (const key of [...new Set(sp.keys())].sort()) {
if (key === 'from' || key === 'to') continue
out[key] = sp.getAll(key)
}
return out
}
beforeEach(() => {
lastSearch = ''
})
afterEach(() => {
cleanup()
vi.unstubAllGlobals()
})
// ---------------------------------------------------------------------
describe('невдала спроба видна одразу', () => {
it('логін, причина, адреса й пристрій — без фільтрів і без розкриття', async () => {
open('/logins', {
'GET /api/v1/logins': page([
attempt({
id: 'a-2',
success: false,
reason: 'bad_password',
reason_label: 'Невірний пароль',
login: 'adm1n',
ip: '203.0.113.7',
}),
attempt(),
]),
})
// Введений логін, а не ім'я людини: одруківка в ньому і є сигналом.
const login = await screen.findByText('adm1n')
// Усе перевіряється В МЕЖАХ РЯДКА. «Невірний пароль» є ще й серед
// фільтрів угорі, і збіг із ним доводив би лише те, що словник
// приїхав, — а питання тут інше: чи видно причину, не розкриваючи
// рядок.
const row = login.closest('button')
expect(row).not.toBeNull()
const cell = within(row as HTMLElement)
expect(cell.getByText('Невірний пароль')).toBeInTheDocument()
expect(cell.getByText('203.0.113.7')).toBeInTheDocument()
expect(cell.getByText('невдало')).toBeInTheDocument()
// Пристрій — стислий переказ User-Agent, який дав сервер.
expect(cell.getByText('curl (скрипт)')).toBeInTheDocument()
// Рахунок угорі: відповідь на «скільки їх» має бути видна раніше,
// ніж людина дочитає перший екран.
expect(screen.getByText(/1 невдала спроба/)).toBeInTheDocument()
// І жодного фільтра для цього не знадобилось.
expect(lastSearch).toBe('')
})
it('«лише невдалі» звужує питання й лягає в адресу', async () => {
const server = open('/logins', {
'GET /api/v1/logins': page([
attempt({ id: 'a-2', success: false, reason: 'bad_password' }),
attempt(),
]),
})
fireEvent.click(await screen.findByRole('button', { name: 'лише невдалі' }))
await waitFor(() => expect(lastSearch).toContain('outcome=failed'))
await waitFor(() => expect(server.of('GET /api/v1/logins?').length).toBe(2))
expect(question(server.of('GET /api/v1/logins?')[1]).outcome).toEqual(['failed'])
})
it('фільтр з адреси й фільтр, обраний руками, дають ОДНАКОВЕ питання', async () => {
// Головна перевірка про фільтри: посилання на звужений перелік
// пересилають колезі, і воно має відкривати ТЕ САМЕ, що людина
// бачила. Перевіряти кожен бік окремо проти очікуваного рядка
// означало б двічі записати те саме припущення.
const fromLink = open('/logins?outcome=failed&reason=bad_password&ip=10.20.0.0%2F24&q=root')
await waitFor(() => expect(fromLink.of('GET /api/v1/logins?').length).toBe(1))
const viaURL = question(fromLink.of('GET /api/v1/logins?')[0])
cleanup()
vi.unstubAllGlobals()
const byHand = open('/logins')
await waitFor(() => expect(byHand.of('GET /api/v1/logins?').length).toBe(1))
fireEvent.click(screen.getByRole('button', { name: 'лише невдалі' }))
fireEvent.click(screen.getByRole('button', { name: 'Невірний пароль' }))
fireEvent.change(screen.getByPlaceholderText('10.20.0.0/24'), {
target: { value: '10.20.0.0/24' },
})
fireEvent.change(screen.getByPlaceholderText('admin, root, пошта…'), {
target: { value: 'root' },
})
await waitFor(() => {
const calls = byHand.of('GET /api/v1/logins?')
expect(question(calls[calls.length - 1])).toEqual(viaURL)
})
})
})
// ---------------------------------------------------------------------
describe('межа показаного названа вголос', () => {
it('неприв’язувані спроби пораховані й пояснені, хоч рядків і немає', async () => {
open('/logins', {
'GET /api/v1/logins': page([attempt()], { unattributed: { attempts: 47, ips: 3 } }),
})
const note = await screen.findByText(/не показані нижче/)
expect(note).toHaveTextContent(/47/)
// Не просто число: сторінка мусить сказати, ЧОМУ їх не видно.
expect(screen.getByText(/логінами, яких у системі немає/)).toBeInTheDocument()
expect(screen.getByText(/логінами, яких у системі немає/)).toHaveTextContent(/3/)
})
it('нуля неприв’язуваних спроб не показуємо — це шум, а не межа', async () => {
open('/logins', {
'GET /api/v1/logins': page([attempt()], { unattributed: { attempts: 0, ips: 0 } }),
})
await screen.findByText('admin')
expect(screen.queryByText(/не показані нижче/)).not.toBeInTheDocument()
})
it('перелік того, чого сторінка не бачить, приходить із сервера й показується', async () => {
open('/logins', { 'GET /api/v1/logins': page([attempt()]) })
expect(await screen.findByText('Чого ця сторінка не бачить')).toBeInTheDocument()
for (const s of meta.blind_spots) {
expect(screen.getByText(s)).toBeInTheDocument()
}
})
it('порожній перелік не читається як «входів не було»', async () => {
open('/logins', { 'GET /api/v1/logins': page([]) })
// Порожнеча тут означає «не прив’язалось», і сторінка мусить
// сказати саме це, а не «нічого не знайдено».
expect(await screen.findByText(/Історія ведеться з/)).toBeInTheDocument()
expect(screen.getByText('Чого ця сторінка не бачить')).toBeInTheDocument()
})
})
// ---------------------------------------------------------------------
describe('гортання', () => {
it('«Показати ще» йде курсором у ЗАКРІПЛЕНОМУ періоді, а не offset-ом', async () => {
const server = open('/logins', {
'GET /api/v1/logins': (c: Call) => {
const sp = new URLSearchParams(c.url.split('?')[1] ?? '')
const body = sp.get('cursor')
? page([attempt({ id: 'a-9', login: 'second' })])
: page([attempt()], { next_cursor: '1756290000000000.a-1' })
return {
status: 200,
ok: true,
statusText: '',
text: async () => JSON.stringify(body),
} as unknown as Response
},
})
fireEvent.click(await screen.findByRole('button', { name: 'Показати ще' }))
await screen.findByText('second')
const calls = server.of('GET /api/v1/logins?')
expect(calls).toHaveLength(2)
const first = new URLSearchParams(calls[0].url.split('?')[1])
const second = new URLSearchParams(calls[1].url.split('?')[1])
expect(second.get('cursor')).toBe('1756290000000000.a-1')
expect(second.get('offset')).toBeNull()
// Період береться з ВІДПОВІДІ сервера, а не рахується заново: інакше
// вхід, що стався між двома запитами, зсунув би вибірку на рядок.
expect(second.get('from')).toBe('2026-08-20T00:00:00Z')
expect(second.get('to')).toBe('2026-08-27T12:00:00Z')
expect(second.get('from')).not.toBe(first.get('from'))
})
})

View file

@ -1890,6 +1890,95 @@ export interface AuditFilter {
cursor?: string cursor?: string
} }
// ---------------------------------------------------------------------
// Історія входів
//
// Форма — з `server/internal/httpapi/logins.go`. Сторінка — рідна сестра
// журналу аудиту: той самий курсор, той самий закріплений період, той
// самий блок про межі показаного.
// ---------------------------------------------------------------------
/** Причина відмови: ключ і його людська назва. */
export interface LoginReasonInfo {
key: string
label: string
}
export interface LoginAttempt {
id: string
ts: string
/** Те, що НАСПРАВДІ ввели в поле логіна. Не завжди пошта. */
login: string
success: boolean
reason?: string
/** Порожня для ключа, якого немає в словнику сервера. */
reason_label?: string
ip?: string
/** User-Agent як є. */
user_agent?: string
/** Приблизний переказ User-Agent. Порожній — сервер не вгадав. */
device?: string
user_id?: string
username?: string
email?: string
full_name?: string
}
/**
* Скільки невдалих спроб за той самий період не належать ЖОДНОМУ
* кабінету два числа й жодного рядка. Чому саме так, а не рядками:
* шапка `server/internal/store/login_attempts.go`.
*/
export interface LoginUnattributed {
attempts: number
ips: number
}
export interface LoginAttemptsPage {
/** Період, у якому відповідь зібрано насправді. */
from: string
to: string
attempts: LoginAttempt[]
/** Порожній означає «більше немає», а не «невідомо». */
next_cursor?: string
/** Приїздить лише з першою сторінкою. */
unattributed?: LoginUnattributed
}
export interface LoginActorOption {
id: string
label: string
}
/** Від якої дати цьому кабінету взагалі є що показати. */
export interface LoginBounds {
first?: string
last?: string
}
export interface LoginsMeta {
reasons: LoginReasonInfo[]
actors: LoginActorOption[]
bounds: LoginBounds
/** Чого сторінка НЕ показує. Мовчати про це не можна. */
blind_spots: string[]
max_range_days: number
}
/** Фільтр — те саме, що читає ручка GET /api/v1/logins. */
export interface LoginsFilter {
from?: string
to?: string
/** '' — усе, 'failed' — лише невдалі, 'success' — лише вдалі. */
outcome?: string
reasons?: string[]
users?: string[]
ip?: string
q?: string
limit?: number
cursor?: string
}
// --- дзеркало архіву конфігів на зовнішньому Git --- // --- дзеркало архіву конфігів на зовнішньому Git ---
/** /**