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>
413 lines
18 KiB
Go
413 lines
18 KiB
Go
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
|
||
}
|