Netpulse_SasS/server/migrations/0063_rls_enforce.sql
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

582 lines
40 KiB
SQL
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.

-- =====================================================================
-- NetPulse :: 0063_rls_enforce.sql
-- Ролі бази: RLS перестає бути декорацією.
--
-- Що є зараз. Політика tenant_isolation стоїть на 68 таблицях, на 56 з
-- них ще й FORCE ROW LEVEL SECURITY. Написано, увімкнено, перевірено
-- очима — і жодного разу не спрацювало. Застосунок ходить у базу роллю
-- netpulse, яку створює образ Postgres зі змінної POSTGRES_USER, тобто
-- bootstrap-суперкористувачем. Суперкористувач обходить RLS беззастережно:
-- ані ENABLE, ані FORCE на нього не діють.
--
-- Тобто ізоляцію кабінетів у продукті тримає рівно одне: те, що кожен
-- запит у server/internal/store дописує `tenant_id = $1` руками. Один
-- забутий предикат в одному з ~26 тисяч рядків цього пакета — і клієнт
-- бачить чужі хости. Другого рубежу немає; він написаний, але вимкнений.
--
-- Що робить ця міграція. Заводить ролі, під якими RLS справді діє, і
-- закриває три діри, через які перехід на таку роль зламався б або,
-- гірше, не зламався б і лишив витік:
--
-- 1. topo.link_live — звичайний VIEW поверх topo.links без
-- security_invoker. Такий вигляд читає таблицю правами ВЛАСНИКА,
-- а не того, хто питає. Тобто навіть під netpulse_app він повертав
-- би лінки всіх кабінетів. Це єдине місце в схемі, де перехід на
-- роль без BYPASSRLS сам собою нічого не змінює.
-- 2. Шість зв'язкових таблиць без жодної політики: core.role_permissions,
-- inv.device_group_members, inv.device_tags, inv.device_credentials,
-- topo.map_shares, bill.invoice_lines. Колонки tenant_id в них
-- немає, тому цикл із 0011 їх не побачив, а руками про них не
-- згадали. Під роллю без BYPASSRLS вони лишаються повністю
-- відкритими крізь кабінети.
-- 3. Шлях автентифікації. 0012 зробив виняток для core.users,
-- core.sessions і core.memberships — але не для core.agents,
-- core.api_tokens, core.agent_enrollments, core.dashboards і
-- core.download_tickets. Усі п'ять читаються за хешем токена ДО
-- того, як тенант відомий, і під RLS повертали б порожньо. Перший,
-- хто це помітить, — жоден зонд не автентифікується.
--
-- Чого ця міграція НЕ робить, і це головне для того, хто її котить.
-- Вона нічого не змінює в поведінці стенду. Застосунок і далі ходить
-- роллю netpulse, суперкористувач і далі обходить усе, що тут написано,
-- включно з новими політиками. Перемикач — не міграція, а DSN
-- (див. deploy/RLS-CUTOVER.md). Це навмисно: накотити схему й перемкнути
-- роль в один момент означало б не мати кроку, на якому можна зупинитись.
-- =====================================================================
-- ---------------------------------------------------------------------
-- 1. Ролі
-- ---------------------------------------------------------------------
-- Три ролі, три різні відповіді на питання «що цій ролі вільно бачити».
--
-- netpulse власник схеми й міграцій. Лишається як є —
-- суперкористувач, створений образом. Ownership 100
-- таблиць на живій базі не передається: ALTER TABLE
-- ... OWNER TO на кожну гіпертаблицю з чанками — це
-- довга блокувальна дія на чужих даних заради нуля
-- користі. Замість цього нові ролі отримують права,
-- а власник лишається тим, ким був.
--
-- netpulse_app API і колектор. NOBYPASSRLS — саме заради цього
-- все й робиться. Працює під політиками; забутий
-- предикат tenant_id тепер означає порожній
-- результат, а не чужі дані.
--
-- netpulse_worker фонові такти, які за побудовою ходять поверх усіх
-- кабінетів. BYPASSRLS. Обґрунтування нижче.
--
-- Чому фонові процеси отримують окрему роль з BYPASSRLS, а не
-- перебирають тенантів у циклі.
--
-- Перебір безпечніший — це правда, і саме так уже влаштована більша
-- частина фонової роботи: SweepRetention і MirrorGit беруть перелік
-- тенантів і далі кожного обробляють через InTenantTx. Ламається не
-- обробка, а ПЕРШИЙ запит — той, що каже, кого саме обробляти. Його
-- перебором не заміниш: щоб дізнатись перелік тенантів, треба спершу
-- прочитати core.tenants поверх тенантів.
--
-- Друга половина фонових тактів гірша за це. Видача завдань зондам —
-- ClaimConfigJobs, ClaimCommandJobs, ClaimIdentifyRequests — це одна
-- інструкція UPDATE ... FOR UPDATE SKIP LOCKED ... RETURNING tenant_id.
-- Вона одночасно і знаходить роботу, і забирає її собі, і повідомляє,
-- чия вона. Розкласти це на «спитати в кожного тенанта окремо» означає
-- замінити один такт на N тактів кожні 5 секунд і власноруч завести
-- голодування: кабінет, який стоїть у циклі першим, вибирає ліміт, а
-- останній не отримує нічого. SKIP LOCKED існує рівно проти цього.
--
-- Тому вибір такий: перебір лишається там, де він уже є (і саме він
-- робить справжню роботу — читання конфігів, розсилку, видалення), а
-- BYPASSRLS видається окремій ролі рівно для запитів-шукачів.
--
-- Чого це коштує і що з цим робити. BYPASSRLS не обмежується політиками
-- за визначенням — обмежити його можна лише GRANT-ами й тим, ХТО ним
-- ходить. Тому друге з'єднання (NETPULSE_DSN_WORKER) і окремий пул у
-- store: код, який ходить у базу від імені запиту користувача, фізично
-- не має доступу до пулу воркера. Перелік методів, яким цей пул
-- дозволено, — у коментарі до Store.bg; він скінченний і його видно
-- одним grep-ом по `s.bg.`.
--
-- Звужувати GRANT-и netpulse_worker до переліку таблиць ця міграція
-- НЕ береться, і це свідомо. Вузький перелік, складений з читання коду,
-- а не з роботи стенду, — це спосіб зупинити бекапи через півтори доби
-- на таблиці, про яку забули. Звуження стоїть у плані розгортання
-- окремим кроком ПІСЛЯ того, як стенд відпрацює тиждень і покаже
-- фактичний перелік через pg_stat_statements.
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'netpulse_app') THEN
CREATE ROLE netpulse_app;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'netpulse_worker') THEN
CREATE ROLE netpulse_worker;
END IF;
END $$;
-- LOGIN без пароля: підключитись досі не можна.
--
-- Пароль у міграції означав би пароль у git і в контрольній сумі
-- public.schema_migrations — тобто змінити його потім не вийшло б, не
-- зачепивши перевірку цілісності міграцій. Пароль видає розгортання
-- (ALTER ROLE ... PASSWORD), і до того моменту роль існує, має всі
-- права й не приймає жодного з'єднання. Це той стан, у якому міграцію
-- безпечно накотити на бойовий стенд заздалегідь.
ALTER ROLE netpulse_app WITH LOGIN NOBYPASSRLS NOSUPERUSER NOCREATEDB NOCREATEROLE;
ALTER ROLE netpulse_worker WITH LOGIN BYPASSRLS NOSUPERUSER NOCREATEDB NOCREATEROLE;
COMMENT ON ROLE netpulse_app IS
'API і колектор. Без BYPASSRLS: ізоляцію кабінетів тримають політики RLS.';
COMMENT ON ROLE netpulse_worker IS
'Фонові такти поверх усіх кабінетів (видача завдань зондам, перелік '
'тенантів, черга подій). BYPASSRLS — тільки для запитів-шукачів; '
'сама робота йде через InTenantTx. Не давати цю роль API.';
-- ---------------------------------------------------------------------
-- 2. Права
-- ---------------------------------------------------------------------
-- Повторний GRANT ON ALL TABLES, хоча 0011 і 0015 його вже робили.
--
-- ON ALL TABLES — це знімок на момент виконання, а не правило. Усе, що
-- з'явилось після 0011, тримається виключно на ALTER DEFAULT PRIVILEGES,
-- і тримається доти, доки кожну наступну міграцію котить ТА САМА роль,
-- що виконала 0011. Відновлення з дампа під іншим користувачем, або
-- колега, який руками накотив один файл від postgres, — і сім таблиць
-- (core.login_attempts, core.user_groups, core.user_group_members,
-- core.group_permissions, tpl.triggers, tpl.auto_assign,
-- ncm.profile_auto_assign) мовчки лишаються без прав. Поки застосунок
-- ходить суперкористувачем, цього не видно взагалі.
GRANT USAGE ON SCHEMA core, inv, topo, ts, ncm, alr, bill, tpl
TO netpulse_app, netpulse_worker;
GRANT SELECT, INSERT, UPDATE, DELETE
ON ALL TABLES IN SCHEMA core, inv, topo, ts, ncm, alr, bill, tpl
TO netpulse_app, netpulse_worker;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA core, inv, topo, ts, ncm, alr, bill, tpl
TO netpulse_app, netpulse_worker;
-- Права за замовчуванням — тепер і на послідовності, і на схему tpl.
--
-- 0011 роздав default privileges лише на TABLES, 0015 повторив ту саму
-- прогалину для tpl. Наслідок поки нульовий: єдиний bigserial у схемі —
-- core.event_outbox.id з 0003, тобто з часів до знімка, а ts.series.id
-- оголошений як GENERATED ALWAYS AS IDENTITY і успадковує права
-- таблиці. Але перший же serial у наступній міграції дав би
-- «permission denied for sequence» на INSERT — помилку, яку побачить
-- не автор міграції, а прод.
ALTER DEFAULT PRIVILEGES IN SCHEMA core, inv, topo, ts, ncm, alr, bill, tpl
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO netpulse_app, netpulse_worker;
ALTER DEFAULT PRIVILEGES IN SCHEMA core, inv, topo, ts, ncm, alr, bill, tpl
GRANT USAGE, SELECT ON SEQUENCES TO netpulse_app, netpulse_worker;
-- Журнал аудиту лишається дописуваним і не редагованим — 0050 зняв
-- UPDATE/DELETE/TRUNCATE, і рядок вище щойно повернув їх разом з усім
-- іншим. Повторюємо зняття; тепер воно вперше має значення, бо роль
-- застосунку більше не власник таблиці.
REVOKE UPDATE, DELETE, TRUNCATE ON core.audit_log FROM netpulse_app, netpulse_worker;
-- ---------------------------------------------------------------------
-- 3. Вигляди: security_invoker
-- ---------------------------------------------------------------------
-- Найдорожчий рядок цієї міграції.
--
-- Звичайний VIEW у Postgres читає базові таблиці правами свого власника.
-- topo.link_live належить netpulse (суперкористувач), а всередині бере
-- topo.links — таблицю під tenant_isolation. Тобто після переходу на
-- netpulse_app усі запити пішли б під політиками, і рівно один лишився
-- б поза ними: завантаження лінків на мапі показувало б лінки всіх
-- кабінетів. Це не гіпотеза про майбутнє, це стан схеми зараз.
--
-- security_invoker перекладає і перевірку прав, і застосування політик
-- на того, хто питає. Права в netpulse_app на topo.links і ts.if_counters
-- є (розділ 2), тому вигляд не ламається; політика ж тепер рахується для
-- нього, а не для власника.
--
-- Під нинішньою роллю-суперкористувачем цей рядок не змінює нічого:
-- обхід RLS у суперкористувача не залежить від того, чиїми правами
-- читається вигляд.
ALTER VIEW topo.link_live SET (security_invoker = true);
ALTER VIEW ts.device_last_icmp SET (security_invoker = true);
-- Безперервні агрегати (ts.icmp_5m, ts.icmp_1h, ts.if_counters_5m,
-- ts.if_counters_1h, ts.samples_5m, ts.samples_1h) свідомо лишаються як
-- є. security_invoker на них TimescaleDB не приймає, та й нічого не
-- дало б: під ними гіпертаблиці, на яких RLS вимкнено з 0011 через
-- несумісність зі стисненням. Їхню ізоляцію, як і всієї телеметрії,
-- тримає предикат tenant_id у запиті — і тепер це єдиний випадок, коли
-- таке формулювання правдиве.
-- ---------------------------------------------------------------------
-- 4. Зв'язкові таблиці
-- ---------------------------------------------------------------------
-- Шість таблиць, які цикл із 0011 не побачив, бо шукав колонку
-- tenant_id, а в них її немає. І це правильно, що немає: власна колонка
-- тенанта у зв'язці могла б розійтися з батьком і тихо створити дірку
-- (те саме міркування записане в 0013 для core.user_group_members).
-- Ізоляція випливає з батька, тому й політика — через EXISTS до батька.
--
-- Ціна: перевірка EXISTS на кожен рядок. Для зв'язок вона неістотна —
-- усі шість читаються не самі по собі, а в JOIN з батьком, тобто
-- планувальник уже має його рядок під рукою, а батьківський ключ
-- індексований первинним ключем самої зв'язки.
--
-- FORCE ROW LEVEL SECURITY поширює політику й на власника таблиці.
-- Ставимо його там, де власник у ці таблиці не пише, — тобто на п'яти з
-- шести. Виняток нижче пояснений окремо, і він важливіший за правило.
-- core.role_permissions: права ролей. Вбудовані ролі (tenant_id IS NULL)
-- спільні для інсталяції й видимі всім — так само, як самі ролі в
-- roles_visible з 0011. WITH CHECK навмисно вужчий за USING: бачити
-- права вбудованої ролі можна, дописувати їх — ні. Права вбудованим
-- ролям видають міграції (0036, 0037, 0050) від власника, а не застосунок.
--
-- І саме тому тут НЕМАЄ FORCE, хоч на решті п'яти він є.
--
-- З FORCE політика поширилась би на власника, а WITH CHECK вимагає
-- `r.tenant_id = core.current_tenant()`. У вбудованої ролі tenant_id
-- порожній, у міграції app.tenant_id теж порожній — тобто наступний
-- `INSERT INTO core.role_permissions VALUES ('…a1', 'нове:право')`,
-- тобто рядок, який у цьому проєкті пишуть кожні кілька міграцій, було
-- б відхилено. Зараз воно спрацювало б однаково, бо власник —
-- суперкористувач і обходить усе; зламалось би того дня, коли власника
-- перестануть тримати суперкористувачем. Пастка, яка чекає рік і
-- спрацьовує в чужих руках, гірша за відсутній FORCE на таблиці, у яку
-- застосунок і так пише лише власні ролі кабінету.
ALTER TABLE core.role_permissions ENABLE ROW LEVEL SECURITY;
CREATE POLICY role_permissions_follow_role ON core.role_permissions
USING (EXISTS (SELECT 1 FROM core.roles r
WHERE r.id = role_id
AND (r.tenant_id IS NULL OR r.tenant_id = core.current_tenant())))
WITH CHECK (EXISTS (SELECT 1 FROM core.roles r
WHERE r.id = role_id AND r.tenant_id = core.current_tenant()));
ALTER TABLE inv.device_group_members ENABLE ROW LEVEL SECURITY;
ALTER TABLE inv.device_group_members FORCE ROW LEVEL SECURITY;
CREATE POLICY dgm_follow_group ON inv.device_group_members
USING (EXISTS (SELECT 1 FROM inv.device_groups g
WHERE g.id = group_id AND g.tenant_id = core.current_tenant()))
WITH CHECK (EXISTS (SELECT 1 FROM inv.device_groups g
WHERE g.id = group_id AND g.tenant_id = core.current_tenant()));
-- Через хост, а не через мітку: мітка й хост обидва належать кабінету,
-- але саме хост є тим, заради чого зв'язку читають, і саме його
-- tenant_id перевіряє решта політик. Одна сторона замість двох — щоб
-- не з'явилось питання, що робити, коли вони розійшлись.
ALTER TABLE inv.device_tags ENABLE ROW LEVEL SECURITY;
ALTER TABLE inv.device_tags FORCE ROW LEVEL SECURITY;
CREATE POLICY device_tags_follow_device ON inv.device_tags
USING (EXISTS (SELECT 1 FROM inv.devices d
WHERE d.id = device_id AND d.tenant_id = core.current_tenant()))
WITH CHECK (EXISTS (SELECT 1 FROM inv.devices d
WHERE d.id = device_id AND d.tenant_id = core.current_tenant()));
-- Найчутливіша з шести: рядок цієї таблиці каже, яким саме доступом
-- ходити на хост. Чужий рядок тут — це не «побачив зайве», а «зайшов
-- на чужий комутатор нашими руками».
ALTER TABLE inv.device_credentials ENABLE ROW LEVEL SECURITY;
ALTER TABLE inv.device_credentials FORCE ROW LEVEL SECURITY;
CREATE POLICY device_credentials_follow_device ON inv.device_credentials
USING (EXISTS (SELECT 1 FROM inv.devices d
WHERE d.id = device_id AND d.tenant_id = core.current_tenant()))
WITH CHECK (EXISTS (SELECT 1 FROM inv.devices d
WHERE d.id = device_id AND d.tenant_id = core.current_tenant()));
ALTER TABLE topo.map_shares ENABLE ROW LEVEL SECURITY;
ALTER TABLE topo.map_shares FORCE ROW LEVEL SECURITY;
CREATE POLICY map_shares_follow_map ON topo.map_shares
USING (EXISTS (SELECT 1 FROM topo.maps m
WHERE m.id = map_id AND m.tenant_id = core.current_tenant()))
WITH CHECK (EXISTS (SELECT 1 FROM topo.maps m
WHERE m.id = map_id AND m.tenant_id = core.current_tenant()));
ALTER TABLE bill.invoice_lines ENABLE ROW LEVEL SECURITY;
ALTER TABLE bill.invoice_lines FORCE ROW LEVEL SECURITY;
CREATE POLICY invoice_lines_follow_invoice ON bill.invoice_lines
USING (EXISTS (SELECT 1 FROM bill.invoices i
WHERE i.id = invoice_id AND i.tenant_id = core.current_tenant()))
WITH CHECK (EXISTS (SELECT 1 FROM bill.invoices i
WHERE i.id = invoice_id AND i.tenant_id = core.current_tenant()));
-- ---------------------------------------------------------------------
-- 5. Шлях автентифікації
-- ---------------------------------------------------------------------
-- 0012 уже описав цей компроміс для людей: вхід відбувається ДО того,
-- як тенант відомий, тому обидві таблиці на цьому шляху читаються
-- повністю, поки app.tenant_id порожній. Нижче — рівно те саме для
-- п'яти таблиць, які тоді пропустили, і кожна з них ламає свою частину
-- продукту, якщо про неї забути:
--
-- core.agents жоден зонд не автентифікується. Це ламається
-- першим і найгучніше: телеметрія, бекапи,
-- команди — усе йде через цей рядок.
-- core.api_tokens інтеграції по REST дістають 401.
-- core.agent_enrollments новий зонд не реєструється.
-- core.dashboards публічне посилання на панель для телевізора
-- в NOC перестає відкриватись.
-- core.download_tickets кнопка «завантажити» віддає 404 замість файлу.
--
-- Вікно навмисно вужче, ніж у 0012. Там політика — просто
-- «current_tenant() IS NULL», бо звузити її не було чим: пошук іде за
-- поштою, а поштова адреса не ознака рядка. Тут ознака є, і вона в
-- самій таблиці — рядок або має чинний токен, або ні. Тому до умови
-- «тенант ще невідомий» додано «і це рядок, за яким узагалі ходять по
-- токену». Різниця практична: витік через SQL-ін'єкцію в цьому вікні
-- віддав би не всю таблицю, а її чинну частину без самих токенів
-- (token_hash — хеш, і саме тому пошук за ним і працює).
-- Автентифікація зонда: пошук за token_hash. Звузити до «неархівних»
-- не можна — у core.agents немає ознаки відкликання, статус pending
-- теж має автентифікуватись (саме так зонд і піднімається вперше).
CREATE POLICY agents_auth_lookup ON core.agents
FOR SELECT USING (core.current_tenant() IS NULL);
-- Зонд у тій самій операції оновлює last_heartbeat_at і статус. Тенант
-- на цьому шляху вже відомий (він прийшов із рядка агента), тому UPDATE
-- дозволений тільки в межах свого кабінету, без винятку для NULL.
-- Наслідок: RecordHeartbeat має або встановлювати app.tenant_id, або
-- ходити пулом воркера. Це не побічний ефект, а вимога.
CREATE POLICY api_tokens_auth_lookup ON core.api_tokens
FOR SELECT USING (
core.current_tenant() IS NULL
AND revoked_at IS NULL
AND (expires_at IS NULL OR expires_at > now())
);
-- last_used_at пишеться відкріпленою горутиною одразу після успішної
-- автентифікації — тенант там уже відомий, але контекст не виставлений.
-- Дозволяємо цей UPDATE у тому ж вікні: інакше єдиним наслідком була б
-- колонка, яка мовчки завмерла, і сторінка токенів, що показує неправду.
CREATE POLICY api_tokens_touch ON core.api_tokens
FOR UPDATE USING (core.current_tenant() IS NULL OR tenant_id = core.current_tenant())
WITH CHECK (core.current_tenant() IS NULL OR tenant_id = core.current_tenant());
-- Реєстрація зонда: RedeemEnrollment відкриває транзакцію без тенанта
-- (він щойно й дізнається його з рядка запрошення), бере запрошення
-- FOR UPDATE, гасить його й заводить агента. Три дії, три політики.
CREATE POLICY enrollments_redeem_lookup ON core.agent_enrollments
FOR SELECT USING (
core.current_tenant() IS NULL
AND used_at IS NULL
AND expires_at > now()
);
CREATE POLICY enrollments_redeem_mark ON core.agent_enrollments
FOR UPDATE USING (core.current_tenant() IS NULL OR tenant_id = core.current_tenant())
WITH CHECK (core.current_tenant() IS NULL OR tenant_id = core.current_tenant());
CREATE POLICY agents_enroll_insert ON core.agents
FOR INSERT WITH CHECK (core.current_tenant() IS NULL OR tenant_id = core.current_tenant());
-- Публічна панель: доступ дає сам токен у посиланні, тенант з'ясовується
-- з рядка. public_token IS NOT NULL звужує вікно до тих панелей, які
-- власник свідомо відкрив, — решта кабінету лишається закритою навіть
-- при порожньому app.tenant_id.
CREATE POLICY dashboards_public_lookup ON core.dashboards
FOR SELECT USING (core.current_tenant() IS NULL AND public_token IS NOT NULL);
-- Квиток на завантаження живе дві хвилини й самодостатній. Умова
-- повторює перевірку в коді, а не замінює її: політика вирішує, чи
-- рядок узагалі видно, код — що з ним робити.
CREATE POLICY tickets_redeem_lookup ON core.download_tickets
FOR SELECT USING (core.current_tenant() IS NULL AND expires_at > now());
CREATE POLICY tickets_redeem_mark ON core.download_tickets
FOR UPDATE USING (core.current_tenant() IS NULL AND expires_at > now())
WITH CHECK (core.current_tenant() IS NULL AND expires_at > now());
-- Прибирання протермінованих квитків — крос-тенантний DELETE без
-- предиката, тобто робота для ролі воркера. Політики для нього тут
-- свідомо немає: інакше довелось би дозволити DELETE у вікні порожнього
-- тенанта, а це вже не «прочитати свій рядок за токеном».
-- ---------------------------------------------------------------------
-- 6. Люди й кабінети
-- ---------------------------------------------------------------------
-- core.users: політики на запис.
--
-- Таблиця глобальна — колонки tenant_id в ній немає навмисно, бо одна
-- людина працює в кількох кабінетах (типовий MSP). З 0011 і 0013 у неї
-- є дві політики на читання й жодної на запис. Наслідок під
-- netpulse_app: CreateUser падає на INSERT, тобто з інтерфейсу не додати
-- людину в команду.
--
-- WITH CHECK (true) на вставці не діра, бо перевіряти в цьому рядку
-- нічого: приналежність до кабінету задає не core.users, а
-- core.memberships, і вона під tenant_isolation з 0011. Створити
-- користувача, не давши йому членства, — це створити рядок, який нікому
-- нічого не відкриває.
CREATE POLICY users_create ON core.users
FOR INSERT WITH CHECK (true);
-- Редагування — лише свого учасника (або у вікні входу, де оновлюється
-- last_login_at і пароль). USING вужчий за WITH CHECK навпаки, ніж
-- зазвичай: рядок, який дозволено змінити, лишається тим самим рядком,
-- бо прив'язки до кабінету в ньому немає.
CREATE POLICY users_update ON core.users
FOR UPDATE USING (
core.current_tenant() IS NULL
OR EXISTS (SELECT 1 FROM core.memberships m
WHERE m.user_id = core.users.id AND m.tenant_id = core.current_tenant())
) WITH CHECK (true);
-- Чого це НЕ дає, і це треба сказати прямо. Додати в кабінет людину,
-- яка вже є в ЧУЖОМУ кабінеті, під netpulse_app не вийде: CreateUser
-- робить INSERT ... ON CONFLICT (username) DO UPDATE ... RETURNING, а
-- RETURNING проходить через політику читання, під яку чужий користувач
-- не підпадає, — і замість «підхопили наявного» вийде помилка
-- унікальності. Політикою це не лікується: щоб її обійти, треба зробити
-- core.users видимою наскрізь, а це рівно та дірка, яку ми закриваємо.
-- Тому CreateUser ходить пулом воркера (див. store/users.go) — дія
-- рідка, адміністративна й крос-тенантна за самою природою.
-- core.tenants: створення кабінету лишається поза застосунком.
--
-- Політика tenant_self з 0011 оголошена як `USING (id =
-- core.current_tenant())` без FOR і без WITH CHECK. Тут легко помилитись
-- у той чи інший бік, тому по пунктах.
--
-- Без WITH CHECK Postgres бере за нього ТУ САМУ умову з USING. Тобто
-- tenant_self насправді закриває всі чотири дії, а не лише читання:
--
-- SELECT видно свій кабінет працює
-- UPDATE старий і новий рядок — свій кабінет працює
-- INSERT новий рядок мусить мати id = поточний неможливо
-- DELETE свій кабінет працює
--
-- Перейменування кабінету, брендування й зміна статусу під netpulse_app
-- працюють без жодної додаткової політики — саме тому її тут немає.
--
-- INSERT неможливий не через недогляд, а тому, що id нового рядка не
-- може дорівнювати поточному тенанту: поточного ще немає. Так і треба —
-- кабінети заводить netpulse-user роллю власника, а політика, яка це
-- дозволила б застосунку, була б дозволом створювати собі сусідів.
--
-- DELETE лишається можливим, і це варто знати: кабінет може видалити
-- сам себе разом з усім, що на ньому висить каскадом. Заборона тут була
-- б новим продуктовим рішенням («хто має право закрити кабінет»), а не
-- частиною роботи про ізоляцію, тому 0063 його не ухвалює.
-- ---------------------------------------------------------------------
-- 7. Перевірка
-- ---------------------------------------------------------------------
-- Три перевірки, які виконуються тут і зараз, а не в проді.
--
-- Сенс саме в тому, щоб міграція впала на стенді розробника, якщо схема
-- не готова до зміни ролі. Наступна людина, яка додасть таблицю з
-- tenant_id і забуде політику, дізнається про це від `go test`, а не
-- від клієнта, у якого зник список хостів.
-- 7.1. Таблиці з tenant_id, на яких немає RLS або немає жодної політики.
--
-- Такі під netpulse_app поводяться по-різному й обидва варіанти погані:
-- без RLS таблиця відкрита крізь кабінети, з RLS без політики — завжди
-- порожня. Гіпертаблиці виключені: RLS на них неможливий, поки ввімкнено
-- стиснення (перевірено на 2.29.1, див. 0011).
DO $$
DECLARE
bad text;
BEGIN
SELECT string_agg(format('%s.%s (%s)', n.nspname, c.relname,
CASE WHEN NOT c.relrowsecurity THEN 'RLS вимкнено' ELSE 'немає політики' END),
E'\n ' ORDER BY n.nspname, c.relname)
INTO bad
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
JOIN pg_attribute a ON a.attrelid = c.oid AND a.attname = 'tenant_id'
AND a.attnum > 0 AND NOT a.attisdropped
WHERE c.relkind = 'r'
AND n.nspname IN ('core','inv','topo','ts','ncm','alr','bill','tpl')
AND NOT EXISTS (SELECT 1 FROM timescaledb_information.hypertables h
WHERE h.hypertable_schema = n.nspname
AND h.hypertable_name = c.relname)
AND (NOT c.relrowsecurity
OR NOT EXISTS (SELECT 1 FROM pg_policy p WHERE p.polrelid = c.oid));
IF bad IS NOT NULL THEN
RAISE EXCEPTION E'таблиці з tenant_id без чинного RLS:\n %', bad
USING HINT = 'ENABLE + FORCE ROW LEVEL SECURITY і політика tenant_isolation, '
'або явний виняток у 0063';
END IF;
END $$;
-- 7.2. Таблиці, до яких у netpulse_app немає доступу взагалі.
--
-- Це та частина, яку неможливо помітити читанням коду: таблиця, що
-- випала з GRANT-ів, під новою роллю дає не порожній результат, а
-- «permission denied» — і виявляється це на першому ж запиті клієнта.
-- Перевіряємо і вигляди: security_invoker щойно переклав перевірку прав
-- на того, хто питає.
DO $$
DECLARE
bad text;
BEGIN
SELECT string_agg(format('%s.%s', n.nspname, c.relname), E'\n '
ORDER BY n.nspname, c.relname)
INTO bad
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relkind IN ('r','v','m','p')
AND n.nspname IN ('core','inv','topo','ts','ncm','alr','bill','tpl')
AND NOT has_table_privilege('netpulse_app', c.oid, 'SELECT');
IF bad IS NOT NULL THEN
RAISE EXCEPTION E'netpulse_app не має SELECT на:\n %', bad
USING HINT = 'GRANT SELECT ... TO netpulse_app; перевірте, чи не створено '
'об''єкт роллю, відмінною від власника решти схеми';
END IF;
END $$;
-- 7.3. Довідка, а не помилка: таблиці без tenant_id і без політик.
--
-- Кожна з них або спільна для всієї інсталяції (core.permissions,
-- bill.plans), або гіпертаблиця, або зв'язка, яку ми щойно закрили.
-- Список друкується, щоб наступний автор побачив його очима, а не
-- дізнався про нову таблицю в цьому переліку через півроку.
DO $$
DECLARE
r record;
n int := 0;
BEGIN
FOR r IN
SELECT n.nspname AS sch, c.relname AS tbl,
EXISTS (SELECT 1 FROM timescaledb_information.hypertables h
WHERE h.hypertable_schema = n.nspname
AND h.hypertable_name = c.relname) AS is_ht
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relkind = 'r'
AND n.nspname IN ('core','inv','topo','ts','ncm','alr','bill','tpl')
AND NOT EXISTS (SELECT 1 FROM pg_attribute a
WHERE a.attrelid = c.oid AND a.attname = 'tenant_id'
AND a.attnum > 0 AND NOT a.attisdropped)
AND NOT EXISTS (SELECT 1 FROM pg_policy p WHERE p.polrelid = c.oid)
ORDER BY 1, 2
LOOP
n := n + 1;
RAISE NOTICE 'без tenant_id і без політик: %.% %', r.sch, r.tbl,
CASE WHEN r.is_ht THEN '(гіпертаблиця)' ELSE '' END;
END LOOP;
RAISE NOTICE 'разом таких таблиць: %', n;
END $$;
-- ---------------------------------------------------------------------
-- 8. Позначки
-- ---------------------------------------------------------------------
COMMENT ON TABLE core.role_permissions IS
'Права ролі. Ізоляція успадкована від core.roles: вбудовані ролі '
'(tenant_id IS NULL) видно всім, дописувати можна лише власним.';
COMMENT ON TABLE inv.device_credentials IS
'Доступи до хоста. Ізоляція успадкована від inv.devices — чужий рядок '
'тут означає вхід на чужий пристрій, а не зайвий запис на екрані.';
COMMENT ON VIEW topo.link_live IS
'Завантаження лінків. security_invoker = true: без нього вигляд читав '
'би topo.links правами власника й віддавав би лінки всіх кабінетів.';