Netpulse_SasS/deploy/RLS-EXISTING-INSTALL.md
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

23 KiB
Raw Permalink Blame History

Увімкнення RLS на інсталяції, старшій за 0063

Цей документ потрібен ЛИШЕ інсталяціям, зробленим до міграції 0063. На системі, розгорнутій із цією версією, RLS увімкнено з першого запуску: netpulse-migrate бачить порожню базу, накочує схему й одразу видає ролям паролі з .env. Робити не треба нічого, читати це теж не треба.

Як перевірити, що це саме ваш випадок:

docker compose exec -T db psql -U netpulse -d netpulse -c \
  "SELECT fresh, applied_was, decided_at FROM public.netpulse_install"

fresh = t — інсталяція народилась із RLS, далі не читайте. fresh = f — база вже працювала, коли її вперше побачив мігратор із підтримкою ролей; перехід на ній робиться руками, і саме про це документ нижче. Таблиці немає взагалі — стенд ще не оновлювався; оновіть образи й накотіть міграції (крок 1), рядок з'явиться.

Як увімкнути справжню ізоляцію кабінетів на живому стенді, що перевіряти після кожного кроку і як відкотитись, якщо застосунок перестане бачити дані.

Чому це не робиться саме

Мігратор уміє видавати ролям паролі й робить це на кожній новій інсталяції. На вашій він цього навмисно не робить, і ознака, за якою він розрізняє два випадки, — не здогад, а записаний факт: чи була public.schema_migrations порожня в ту мить, коли мігратор уперше побачив цю базу. Відповідь пишеться один раз у public.netpulse_install і більше не переглядається.

Причина проста. На порожній базі перемикати нічого: немає ані даних, ані клієнтів, ані стану, у який можна повернутись. На вашій — є все три. Перемикання роллю на живому стенді має вікно, у якому зонди можуть замовкнути, і мусить мати крок, на якому можна зупинитись. Тому воно лишається процедурою, а docker compose up його не запускає.

Що саме змінюється

Політики Row Level Security написані в схемі з міграції 0011 і стоять на 68 таблицях. Жодна з них ніколи не спрацьовувала: docker-compose.yml збирає DSN із ролі netpulse, а її створює образ Postgres зі змінної POSTGRES_USER, тобто bootstrap-суперкористувачем. Суперкористувач обходить RLS беззастережно.

Тобто ізоляцію кабінетів у продукті тримає рівно одне: те, що кожен запит у server/internal/store дописує tenant_id = $1 руками. Один забутий предикат — і клієнт бачить чужі хости. Другий рубіж написаний, увімкнений у схемі й вимкнений у житті.

Перехід дає три ролі:

роль BYPASSRLS хто ходить
netpulse так (суперкористувач) migrate, netpulse-user, netpulse-secret
netpulse_app ні api, collector
netpulse_worker так фонові такти всередині api і collector, netpulse-gitsync

netpulse_worker лишається з BYPASSRLS свідомо: запити-шукачі черг (ClaimConfigJobs, ClaimCommandJobs, DuePolicies, FetchEvents) — це одна інструкція UPDATE … FOR UPDATE SKIP LOCKED … RETURNING tenant_id на всю інсталяцію. Розкласти її по кабінетах означає замінити один такт на N тактів кожні 5 секунд і завести голодування. Обґрунтування цілком — у коментарі до 0063_rls_enforce.sql, розділ 1.

Чого цей перехід не робить

  • Не змінює телеметрію. Гіпертаблиці (ts.*, core.audit_log, alr.alerts_history, alr.notifications) під RLS не підпадають — і не можуть, поки ввімкнено стиснення. Їхню ізоляцію й далі тримає предикат у запиті. Це не наслідок переходу, а незмінна властивість TimescaleDB.
  • Не рятує від BYPASSRLS у воркера. Роль воркера бачить усе. Її обмежує не RLS, а те, ким і звідки вона використовується: окремий пул у store.Store.bg, окремий пароль, скінченний перелік методів (grep -rn 's\.bg\.' server/internal/store/).
  • Не переносить володіння об'єктами. netpulse лишається власником усіх 100+ таблиць. Передавати ownership на живій базі — це ALTER TABLE … OWNER TO на кожну гіпертаблицю з чанками, тобто довга блокувальна дія на чужих даних заради нуля користі.

Передумови

  • Свіжий дамп бази (## Бекап у deploy/README.md) — знятий сьогодні, не «десь був».
  • Вікно, у якому допустимо, що зонди на кілька хвилин перестануть доповідати. Дані за цей час не губляться: агент тримає їх у себе й дошле, — але алерти в цей проміжок не рахуються.
  • Доступ до docker compose exec db psql.

Далі всюди мається на увазі, що ви в каталозі з docker-compose.yml.


Крок 1. Накотити 0063

git pull
docker compose build
docker compose run --rm migrate

Це безпечно й нічого не вмикає. 0063 заводить ролі без пароля (підключитись ними ще не можна), роздає права, закриває політиками шість зв'язкових таблиць і ставить security_invoker на два вигляди. Поки застосунок ходить суперкористувачем, жодна з цих змін на нього не діє.

Міграція сама себе перевіряє: якщо в схемі є таблиця з tenant_id без RLS або без політики, або таблиця, до якої netpulse_app не має SELECT, вона впаде з переліком таких таблиць. Падіння тут означає «переходити ще рано», а не «щось зламалось».

На цьому ж запуску мігратор запише public.netpulse_install — рядок, який назавжди фіксує, що ця база НЕ була порожньою, коли він її вперше побачив. Саме через нього все подальше лишається ручним: паролі ролям на такій базі він не видасть ні зараз, ні через рік, скільки б рядків не з'явилось у .env.

Перевірити:

docker compose exec -T db psql -U netpulse -d netpulse -c \
  "SELECT rolname, rolcanlogin, rolbypassrls FROM pg_roles
    WHERE rolname LIKE 'netpulse%' ORDER BY 1"

Очікується рівно це:

    rolname      | rolcanlogin | rolbypassrls
-----------------+-------------+--------------
 netpulse        | t           | t
 netpulse_app    | t           | f
 netpulse_worker | t           | t

rolbypassrls = f у netpulse_app — головний рядок цієї таблиці. Якщо там t, далі йти немає сенсу: усе наступне пройде, і не змінить нічого.

Застосунок на цьому кроці не чіпаємо. Можна зупинитись тут на добу.


Крок 2. Видати паролі

Паролі не лежать у міграції навмисно: у git і в контрольній сумі public.schema_migrations вони були б назавжди.

Тільки hex. Пароль ролі їде всередині DSN postgres://роль:пароль@db:5432/netpulse, і не кожен символ лишається там собою. openssl rand -base64 рано чи пізно видасть / — скісна риска обриває пароль і перетворює його хвіст на ім'я бази: з'єднання не встановлюється, у журналі стоїть «database … does not exist». Гірший випадок — %: розбирач читає його як початок %XX, помилки немає, а пароль мовчки стає іншим рядком. Виглядає це не як зіпсований рядок у .env, а як «RLS усе зламав», і півдня цього проєкту коштувало саме воно.

APP_PW=$(openssl rand -hex 24)
WRK_PW=$(openssl rand -hex 24)

docker compose exec -T db psql -U netpulse -d netpulse <<SQL
ALTER ROLE netpulse_app    PASSWORD '$APP_PW';
ALTER ROLE netpulse_worker PASSWORD '$WRK_PW';
SQL

echo "NETPULSE_APP_PASSWORD=$APP_PW"
echo "NETPULSE_WORKER_PASSWORD=$WRK_PW"

Перевірити, що ролі підключаються:

docker compose exec -T db env PGPASSWORD="$APP_PW" \
  psql -U netpulse_app -d netpulse -c "SELECT current_user, rolbypassrls
    FROM pg_roles WHERE rolname = current_user"

Застосунок і тут не чіпаємо: ролі є, паролі є, ніхто ними ще не ходить.


Крок 3. Перевірити ізоляцію на цій самій базі

Це той крок, який відрізняє «RLS увімкнено» від «RLS працює». Робиться на бойовій базі, нічого в неї не пишучи: SET ROLE перемикає діючого користувача в межах однієї сесії, а ROLLBACK наприкінці не лишає слідів.

Підставте реальні id двох різних кабінетів:

docker compose exec -T db psql -U netpulse -d netpulse <<'SQL'
BEGIN;
SET ROLE netpulse_app;

SET LOCAL app.tenant_id = 'ПЕРШИЙ-КАБІНЕТ-UUID';
SELECT 'бачить своїх'  AS q, count(*) FROM inv.devices;
SELECT 'бачить чужих'  AS q, count(*) FROM inv.devices
  WHERE tenant_id <> 'ПЕРШИЙ-КАБІНЕТ-UUID';
SELECT 'зв''язки: доступи' AS q, count(*) FROM inv.device_credentials;
SELECT 'вигляд лінків'     AS q, count(*) FROM topo.link_live;

SET LOCAL app.tenant_id = '';
SELECT 'без контексту' AS q, count(*) FROM inv.devices;

RESET ROLE;
ROLLBACK;
SQL

Очікується: «бачить своїх» — реальна кількість хостів кабінету, «бачить чужих» — 0, «без контексту» — 0. Кількість у зв'язках і у вигляді topo.link_live має відповідати цьому ж кабінету, а не всій інсталяції.

Якщо «бачить чужих» більше нуля — зупиніться. Далі йти не можна: це означає, що якась таблиця лишилась без політики, і перехід дасть хибне відчуття захисту замість захисту.

Той самий сценарій у вигляді тесту, який ганяється на одноразовій базі: server/internal/store/rls_isolation_test.go.


Крок 4. Перемкнути застосунок

У .env дописати два рядки (пароль ролі-власника лишається на місці — ним ходять міграції й утиліти):

NETPULSE_APP_PASSWORD=<APP_PW з кроку 2>
NETPULSE_WORKER_PASSWORD=<WRK_PW з кроку 2>

Обидва — разом. NETPULSE_APP_PASSWORD без NETPULSE_WORKER_PASSWORD дає найгірший з можливих станів: інтерфейс працює, а фонові такти мовчки нічого не знаходять — бекапи не запускаються, алерти не розсилаються, події не доходять до браузера, і жодної помилки в журналі при цьому немає. Тепер цю пару перевіряє мігратор і зупиняє запуск, але покладатись на це не варто: він рятує від забутого рядка, а не від неправильного пароля.

Окремої змінної NETPULSE_APP_USER більше немає. Раніше вона була, і будь-яка з двох половин без другої давала DSN, який не встановлюється: нова роль зі старим паролем або стара роль з новим. Тепер ім'я ролі випливає з наявності пароля. Якщо NETPULSE_APP_USER лишився у вашому .env — його просто ігнорують, видаляти не обов'язково.

docker compose up -d api collector

api і collector залежать від migrate, тож перед ними ще раз відпрацює мігратор. Схему він не змінить (вона актуальна), паролів ролям не видасть (база непорожня), але зайде обома DSN і перевірить, що netpulse_app заходить і не має BYPASSRLS, а netpulse_worker заходить і має. Якщо крок 2 пропущено, ви побачите це тут, а не за годину в журналі колектора.

Перевірити протягом перших п'яти хвилин:

  1. Інтерфейс. Увійти й відкрити перелік хостів. Порожній перелік у непорожньому кабінеті — ознака, що щось лишилось без політики.

    curl -sf https://$NETPULSE_DOMAIN/healthz
    
  2. Зонди. Це ламається першим, якщо ламається:

    docker compose exec -T db psql -U netpulse -d netpulse -c \
      "SELECT status, count(*), max(last_heartbeat_at) FROM core.agents GROUP BY 1"
    

    max(last_heartbeat_at) має бути свіжішим за хвилину. Якщо він застиг на моменті перезапуску — агенти не автентифікуються, і це видно ще й у журналі колектора:

    docker compose logs --since 5m collector | grep -i unauth
    
  3. Телеметрія доходить:

    docker compose exec -T db psql -U netpulse -d netpulse -c \
      "SELECT max(ts) FROM ts.icmp_samples"
    
  4. Фонові такти живі. Черга завдань не має рости монотонно:

    docker compose exec -T db psql -U netpulse -d netpulse -c \
      "SELECT status, count(*) FROM ncm.jobs GROUP BY 1"
    

    Повторити через п'ять хвилин. Якщо queued росте, а running і done стоять — воркер не бачить черги, тобто NETPULSE_WORKER_PASSWORD не доїхало.

  5. Події доходять у браузер. Відкрита сторінка має оновлювати статуси без перезавантаження. Непрямо:

    docker compose exec -T db psql -U netpulse -d netpulse -c \
      "SELECT count(*) FROM core.event_outbox WHERE published_at IS NULL"
    

    Число має коливатись, а не тільки зростати.

Перевірити протягом першої доби:

  1. Бекап конфігів відпрацював за розкладом:

    docker compose exec -T db psql -U netpulse -d netpulse -c \
      "SELECT max(created_at) FROM ncm.configs"
    
  2. Алерти рахуються. Погасити тестовий хост і переконатись, що алерт з'явився й прийшов у канал.

  3. Журнал аудиту показує імена акторів, а не порожні клітинки. Це єдине місце, де помилка виглядає правдоподібно: перелік подій лишається, а колонка «хто» стає порожньою.


Крок 5. Утиліти командного рядка

netpulse-user (заводить кабінети й людей) і netpulse-secret (кладе паролі в core.secrets) роблять рівно те, чого роль під RLS робити не має. Після переходу їм потрібен DSN власника — для цього в docker-compose.yml є окрема служба cli:

docker compose run --rm --entrypoint netpulse-user cli \
  -tenant default -login admin -role owner

Без неї вони не впадуть з помилкою, а мовчки нічого не знайдуть.

netpulse-gitsync, навпаки, ходить NETPULSE_DSN_WORKER і працює як є — перелік кабінетів він бере крос-тенантним запитом.


Відкат

Відкат — не міграція. 0063 нічого не ламає й лишається накоченим; назад повертається тільки те, якою роллю ходить застосунок.

Швидкий (30 секунд, без втрати даних):

# У .env закоментувати або прибрати два рядки:
#   NETPULSE_APP_PASSWORD, NETPULSE_WORKER_PASSWORD
docker compose up -d api collector

DSN згортається до netpulse — тобто до стану «до переходу», разом із BYPASSRLS. Перевірка та сама, що на кроці 4, пункти 14.

Прибирати треба обидва рядки, і саме тому їх лишилось два, а не три: одна змінна вирішує і роль, і пароль, тому «прибрав половину» більше не є станом, у який можна потрапити.

Це працює, бо порожній NETPULSE_APP_PASSWORD збирає старий DSN, а порожній NETPULSE_WORKER_PASSWORD лишає NETPULSE_DSN_WORKER порожнім — і store.UseWorkerDSN тоді просто не відкриває другий пул, а фонові запити йдуть основним. Жодного коду вимикати не треба.

Якщо відкат не допоміг — значить справа не в ролях, і схема тут ні до чого: 0063 не змінює жодної таблиці з даними. Дивіться, що ще поїхало разом із цим релізом.

Схему назад не котять. Зворотних міграцій у проєкті немає навмисно (deploy/README.md, ## Оновлення), і 0063 тут не виняток. Якщо треба прибрати саме її наслідки — це три команди, і жодна не чіпає даних:

ALTER ROLE netpulse_app  NOLOGIN;
ALTER ROLE netpulse_worker NOLOGIN;
ALTER VIEW topo.link_live SET (security_invoker = false);

Політики на зв'язкових таблицях лишати можна: під суперкористувачем вони не діють.


Що зробити потім

  1. Звузити права netpulse_worker. Зараз він має SELECT, INSERT, UPDATE, DELETE на все — успадковано з 0011. Звужувати наосліп, за читанням коду, — спосіб зупинити бекапи через півтори доби на таблиці, про яку забули. Правильний порядок: дати стенду відпрацювати тиждень, зняти фактичний перелік і звузити за ним.

    -- увімкнути на добу, потім зняти перелік
    ALTER SYSTEM SET pg_stat_statements.track = 'all';
    SELECT calls, query FROM pg_stat_statements
     WHERE userid = 'netpulse_worker'::regrole ORDER BY calls DESC;
    
  2. Прибрати другий рубіж там, де він більше не потрібен? Ні. Явний tenant_id = $1 у запитах лишається: на гіпертаблицях він єдиний, а на решті — те, що робить план запиту передбачуваним (політика додає умову, індекс використовує предикат).

  3. Стежити за новими таблицями. Перевірка в 0063 разова — вона спрацювала на момент накочування. Наступна таблиця з tenant_id без політики знову з'явиться мовчки. Найдешевше — повторити ту саму перевірку в наступній міграції, що додає таблиці.