Один коміт, а не десяток тематичних, свідомо: теми переплетені в
спільних файлах (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 серпня.
334 lines
16 KiB
Go
334 lines
16 KiB
Go
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"
|
||
}
|