Netpulse_SasS/server/internal/store/rls_isolation_test.go
byrsapty ed8fc831bf Дві сесії роботи: 0058–0068, розгортання однією командою, тести
Один коміт, а не десяток тематичних, свідомо: теми переплетені в
спільних файлах (store.go, docker-compose.yml, deploy/README.md), і
розділити їх можна було б лише індексуванням шматків. Коміти, які не
збираються, гірші за один великий — тим паче що це рівно той стан, який
перевірявся разом.

ЩО ПРАЦЮЄ НА СТЕНДІ Й ПЕРЕВІРЕНО ТАМ

  0058  подієві алерти: syslog, ncm, compliance спрацьовують у мить
        події; правило з нереалізованим джерелом більше не зберігається
        мовчки
  0059  snmp.walk і прототипи шаблонів — таблиці з динамічним індексом
        описуються шаблоном, а не Go
  0060  відкат конфігу: план як різниця, маскування паролів із підписом
        плану, обов'язковий контрольний збір, verifying при обриві
  0061  кнопки Telegram: довге опитування, авторизація не з callback_data
  0062  аудит і архів хостів; тест на AST, що падає на ключі без назви
  0063  RLS: три ролі, окремий пул для фонових тактів
  0064  строки зберігання даних і сторінка сховища
  0065  приймач SNMP-трапів; перевірено справжніми пакетами по дроту,
        переклад v1→v2 за RFC 3584 дає правильний OID
  0066  ескалації сповіщень
  0067  алерт про вичерпання диска
  0068  поля заливки конфігу переїхали в каталог профілів

Плюс: 137 тестів вебу з нуля (їх не було взагалі), одинадцять справжніх
вад, знайдених ними й виправлених, і виправлення двох інтеграційних
тестів grpcapi, які мовчки пропускались півтора року.

ЩО ЩЕ НЕ ЗАПУСКАЛОСЬ

  netpulse            установник: одна команда замість 18 змінних і
                      593 рядків інструкції
  RLS з першого запуску  нова інсталяція під політиками одразу;
                      RLS-EXISTING-INSTALL.md лишається тільки для
                      старих інсталяцій
  .forgejo + CI       раннер не зареєстрований

Ці три перевірені компіляцією й міркуванням, але не виконанням.

ГОЛОВНИЙ ВИСНОВОК ДВОХ СЕСІЙ

Зелена перевірка доводить рівно те, що вона перевіряє. Тест ізоляції RLS
був правильний і зелений — і пропустив зламаний вхід, бо перевіряв «чи
не видно чужого», коли зламалось «чи видно своє». Інтеграційні тести
grpcapi були зелені, бо не виконувались. Схема, довідник і протокол
описували те, чого в коді не існувало, і виглядало це як готове.

Тому в кожному завданні цих сесій стояла вимога назвати НЕПОКРИТЕ, а
чотири задачі закінчились не можливістю, а відмовою: правило з
нереалізованим джерелом не зберігається, профіль без команд заливки
каже про це замість мовчазної кнопки, міграція RLS валить сама себе на
таблиці без політики, тест словника аудиту падає на ключі без назви.

Подробиці — HISTORY.md, розділи за 26 і 27 серпня.
2026-08-27 17:32:49 +03:00

334 lines
16 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package store
import (
"context"
"os"
"strings"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// Доказ того, що ізоляція кабінетів працює, а не оголошена.
//
// Різниця між «RLS увімкнено» і «RLS діє» — це рівно одна властивість
// ролі, якою відкрито з'єднання, і побачити її можна тільки з бази.
// Читанням схеми — не можна: ENABLE ROW LEVEL SECURITY, FORCE ROW LEVEL
// SECURITY і сама політика в pg_policies виглядають однаково і там, де
// вони працюють, і там, де їх обходить rolbypassrls. Саме тому цей тест
// не перевіряє наявність політик — він перевіряє результат запиту.
//
// Влаштований на SET ROLE, а не на другому DSN. Причин дві. Пароля
// netpulse_app у тесті немає й не має бути; і головне — SET ROLE
// перемикає рівно те, що перемикає розгортання (діючого користувача),
// лишаючи все інше тим самим з'єднанням і тією ж базою. Тобто якщо тест
// зелений, а прод — ні, різниця не в тестовому оточенні.
//
// Тест мовчки пропускається без NETPULSE_TEST_DSN: `go test ./...` не
// має вимагати бази. Запускати треба на ОДНОРАЗОВІЙ базі — він створює
// два кабінети й видаляє їх разом з усім вмістом:
//
// docker run --rm -d --name np-test -e POSTGRES_PASSWORD=x \
// -e POSTGRES_DB=np timescale/timescaledb:2.17.2-pg16
// NETPULSE_DSN=postgres://postgres:x@localhost/np go run ./cmd/netpulse-migrate
// NETPULSE_TEST_DSN=postgres://postgres:x@localhost/np \
// go test ./internal/store/ -run RLS -v
func TestRLSTenantIsolation(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, а не defer: прибирання кабінетів реєструється в
// seedTenant теж через t.Cleanup, а ті виконуються ПІСЛЯ всіх defer
// тіла тесту. З `defer pool.Close()` пул закрився б раніше, ніж
// прибирання встигло б ним скористатись, — і на базі CI, яка живе
// між прогонами, лишалась би пара кабінетів за кожен запуск.
// Реєстрація тут же робить закриття пулу останнім за LIFO.
t.Cleanup(pool.Close)
// Перше, що перевіряємо, — саму передумову.
//
// Якщо netpulse_app колись отримає BYPASSRLS (наприклад, хтось
// «тимчасово» видасть його, щоб полагодити фоновий такт), усі
// перевірки нижче почнуть проходити з протилежної причини: не тому,
// що політики працюють, а тому, що їх немає кому застосувати. Тест,
// який зеленіє від зняття захисту, гірший за відсутній.
var bypass bool
if err := pool.QueryRow(ctx,
`SELECT rolbypassrls FROM pg_roles WHERE rolname = 'netpulse_app'`,
).Scan(&bypass); err != nil {
t.Fatalf("роль netpulse_app: %v (чи накочено 0063?)", err)
}
if bypass {
t.Fatal("netpulse_app має BYPASSRLS — під такою роллю жодна політика не діє")
}
a := seedTenant(ctx, t, pool, "rls-a")
b := seedTenant(ctx, t, pool, "rls-b")
// Контрольний замір роллю власника.
//
// Без нього тест, у якому наливання даних мовчки не спрацювало,
// пройшов би блискуче: нуль чужих рядків — це і є очікуваний
// результат. Тому спершу переконуємось, що чужі рядки взагалі є і
// що їх видно тому, кому має бути видно.
var total int
if err := pool.QueryRow(ctx,
`SELECT count(*) FROM inv.devices WHERE tenant_id IN ($1,$2)`, a.id, b.id,
).Scan(&total); err != nil {
t.Fatalf("контрольний замір: %v", err)
}
if total != 4 {
t.Fatalf("власник бачить %d хостів замість 4 — дані не налились", total)
}
// Далі все на ОДНОМУ з'єднанні: SET ROLE і app.tenant_id живуть у
// межах сесії, а pgxpool видає з'єднання довільно.
conn, err := pool.Acquire(ctx)
if err != nil {
t.Fatalf("з'єднання: %v", err)
}
defer conn.Release()
if _, err := conn.Exec(ctx, `SET ROLE netpulse_app`); err != nil {
t.Fatalf("SET ROLE netpulse_app: %v", err)
}
defer func() {
// RESET до звільнення з'єднання: інакше воно повернеться в пул
// із чужою роллю, і прибирання наприкінці впаде на правах.
_, _ = conn.Exec(context.WithoutCancel(ctx), `RESET ROLE`)
}()
setTenant := func(id string) {
t.Helper()
if _, err := conn.Exec(ctx, `SELECT set_config('app.tenant_id', $1, false)`, id); err != nil {
t.Fatalf("app.tenant_id = %s: %v", id, err)
}
}
count := func(what, q string, args ...any) int {
t.Helper()
var n int
if err := conn.QueryRow(ctx, q, args...).Scan(&n); err != nil {
t.Fatalf("%s: %v", what, err)
}
return n
}
// -----------------------------------------------------------------
// Читання
// -----------------------------------------------------------------
setTenant(a.id)
// Головна перевірка всієї роботи: запит БЕЗ предиката tenant_id.
//
// Саме такий запит і є тією помилкою, від якої страхує RLS, — один
// забутий WHERE в одному з двох з десятків тисяч рядків шару store.
// Якщо тут повернеться 2, значить ізоляція тримається виключно на
// уважності автора запиту, тобто рівно на тому, на чому вона трималась
// до 0063.
if n := count("хости кабінету А", `SELECT count(*) FROM inv.devices`); n != 2 {
t.Errorf("під тенантом А запит без предиката бачить %d хостів, очікувалось 2", n)
}
// Адресне звернення за чужим id. Відрізняється від попереднього тим,
// що тут зловмисник уже ЗНАЄ ідентифікатор — саме так виглядає
// реальний витік через підставлений у URL uuid.
if n := count("чужий хост за id",
`SELECT count(*) FROM inv.devices WHERE id = $1`, b.deviceID); n != 0 {
t.Errorf("тенант А бачить хост тенанта Б за прямим id")
}
// Зв'язкові таблиці. До 0063 на них не було ані RLS, ані політики:
// колонки tenant_id в них немає, тож цикл 0011 їх не помітив.
if n := count("мітки хостів", `SELECT count(*) FROM inv.device_tags`); n != 1 {
t.Errorf("inv.device_tags: видно %d рядків, очікувався 1", n)
}
if n := count("доступи до хостів", `SELECT count(*) FROM inv.device_credentials`); n != 1 {
t.Errorf("inv.device_credentials: видно %d рядків, очікувався 1 "+
"(чужий рядок тут — це вхід на чужий комутатор)", n)
}
// Вигляд. topo.link_live до 0063 читав topo.links правами власника,
// тобто лишався б крос-тенантним навіть під netpulse_app.
if n := count("лінки у вигляді", `SELECT count(*) FROM topo.link_live`); n != 1 {
t.Errorf("topo.link_live: видно %d лінків, очікувався 1 "+
"(перевірте security_invoker на вигляді)", n)
}
// -----------------------------------------------------------------
// Запис
// -----------------------------------------------------------------
// Чужий рядок не змінюється: UPDATE проходить, зачіпає нуль рядків.
tag, err := conn.Exec(ctx,
`UPDATE inv.devices SET name = 'зламано' WHERE id = $1`, b.deviceID)
if err != nil {
t.Fatalf("UPDATE чужого хоста: %v", err)
}
if tag.RowsAffected() != 0 {
t.Errorf("UPDATE зачепив %d чужих рядків", tag.RowsAffected())
}
// Підкладання чужого tenant_id у вставку має бути помилкою, а не
// тихим записом: WITH CHECK відпрацьовує до запису, тому рядок не
// з'явиться навіть на мить.
_, err = conn.Exec(ctx, `
INSERT INTO inv.devices (tenant_id, name, address, kind)
VALUES ($1, 'підкинутий', '10.77.0.1', 'other')`, b.id)
if err == nil {
t.Error("вставка з чужим tenant_id пройшла — WITH CHECK не спрацював")
} else if !strings.Contains(err.Error(), "row-level security") {
t.Errorf("вставка з чужим tenant_id впала не на RLS, а на: %v", err)
}
// -----------------------------------------------------------------
// Другий кабінет і порожній контекст
// -----------------------------------------------------------------
setTenant(b.id)
if n := count("хости кабінету Б", `SELECT count(*) FROM inv.devices`); n != 2 {
t.Errorf("під тенантом Б видно %d хостів, очікувалось 2", n)
}
if n := count("хост А під тенантом Б",
`SELECT count(*) FROM inv.devices WHERE id = $1`, a.deviceID); n != 0 {
t.Errorf("тенант Б бачить хост тенанта А")
}
// Забутий SET LOCAL має давати порожньо, а не все.
//
// Це та властивість, заради якої core.current_tenant() повертає
// NULL, а не кидає помилку: «нічого не видно» — стан, у якому
// продукт зламаний і це помітно за хвилину; «видно все» — стан, у
// якому продукт виглядає справним рівно до першої скарги клієнта.
if _, err := conn.Exec(ctx, `SELECT set_config('app.tenant_id', '', false)`); err != nil {
t.Fatalf("скидання app.tenant_id: %v", err)
}
if n := count("без контексту", `SELECT count(*) FROM inv.devices`); n != 0 {
t.Errorf("без app.tenant_id видно %d хостів — має бути 0", n)
}
// Шлях входу — виняток, і він має лишатись робочим: пошук
// користувача за логіном відбувається до того, як тенант відомий.
if n := count("вхід без контексту",
`SELECT count(*) FROM core.users WHERE username = $1`, a.username); n != 1 {
t.Errorf("користувача не видно у вікні входу — автентифікація зламана")
}
}
type rlsTenant struct {
id string
deviceID string
username string
}
// seedTenant наливає мінімальний, але зв'язний кабінет.
//
// Мінімальний означає «по одному рядку в кожну таблицю, яку перевіряє
// тест», а не «один хост»: половина перевірок вище — саме про зв'язки
// (мітки, доступи, лінк), бо саме вони до 0063 лишались без політик.
func seedTenant(ctx context.Context, t *testing.T, pool *pgxpool.Pool, prefix string) rlsTenant {
t.Helper()
var r rlsTenant
suffix := time.Now().Format("150405.000")
suffix = strings.ReplaceAll(suffix, ".", "")
slug := prefix + "-" + suffix
r.username = slug + "-user"
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, "Перевірка RLS "+prefix).Scan(&r.id))
// Прибирання за собою — каскадом від кабінету. Усе, що тест створює,
// висить на core.tenants через ON DELETE CASCADE, окрім користувача:
// він глобальний і чіпляється членством.
t.Cleanup(func() {
bg := context.Background()
_, _ = pool.Exec(bg, `DELETE FROM core.tenants WHERE id = $1`, r.id)
_, _ = pool.Exec(bg, `DELETE FROM core.users WHERE username = $1`, r.username)
})
must("хост", pool.QueryRow(ctx, `
INSERT INTO inv.devices (tenant_id, name, address, kind)
VALUES ($1, $2, $3, 'switch') RETURNING id::text
`, r.id, slug+"-sw", "10.66."+randOctet(prefix)+".1").Scan(&r.deviceID))
var ifaceID string
must("порт", pool.QueryRow(ctx, `
INSERT INTO inv.interfaces (tenant_id, device_id, if_index, name)
VALUES ($1, $2, 1, 'ge-0/0/1') RETURNING id::text
`, r.id, r.deviceID).Scan(&ifaceID))
var tagID string
must("мітка", pool.QueryRow(ctx, `
INSERT INTO inv.tags (tenant_id, key, value) VALUES ($1, 'site', $2)
RETURNING id::text
`, r.id, slug).Scan(&tagID))
_, err := pool.Exec(ctx, `
INSERT INTO inv.device_tags (device_id, tag_id) VALUES ($1, $2)
`, r.deviceID, tagID)
must("мітка хоста", err)
var credID string
must("доступ", pool.QueryRow(ctx, `
INSERT INTO inv.credentials (tenant_id, name, proto, username)
VALUES ($1, $2, 'ssh', 'admin') RETURNING id::text
`, r.id, slug+"-ssh").Scan(&credID))
_, err = pool.Exec(ctx, `
INSERT INTO inv.device_credentials (device_id, credential_id) VALUES ($1, $2)
`, r.deviceID, credID)
must("доступ хоста", err)
// Лінк сам на себе через один порт заборонений CHECK-ом, тому
// другий хост. Він же робить перевірку чеснішою: у кабінеті стає
// два хости, і «видно 1» під чужим тенантом уже не можна пояснити
// тим, що хост у базі один.
var peerID string
must("другий хост", pool.QueryRow(ctx, `
INSERT INTO inv.devices (tenant_id, name, address, kind)
VALUES ($1, $2, $3, 'router') RETURNING id::text
`, r.id, slug+"-rt", "10.66."+randOctet(prefix)+".2").Scan(&peerID))
var peerIface string
must("порт другого хоста", pool.QueryRow(ctx, `
INSERT INTO inv.interfaces (tenant_id, device_id, if_index, name)
VALUES ($1, $2, 1, 'ge-0/0/1') RETURNING id::text
`, r.id, peerID).Scan(&peerIface))
_, err = pool.Exec(ctx, `
INSERT INTO topo.links (tenant_id, a_device_id, a_interface_id,
b_device_id, b_interface_id, capacity_bps)
VALUES ($1, $2, $3, $4, $5, 1000000000)
`, r.id, r.deviceID, ifaceID, peerID, peerIface)
must("лінк", err)
must("користувач", pool.QueryRow(ctx, `
INSERT INTO core.users (username, password_hash) VALUES ($1, 'x')
RETURNING id::text
`, r.username).Scan(new(string)))
return r
}
// randOctet розводить адреси двох кабінетів, щоб не спіткнутись об
// унікальність адреси в межах інсталяції там, де вона є.
func randOctet(prefix string) string {
if strings.HasSuffix(prefix, "a") {
return "10"
}
return "20"
}