Netpulse_SasS/server/migrations/0011_rls.sql
byrsapty 205dd5e079 Пакування: runner міграцій і вшитий у бінарник фронтенд
netpulse-migrate замість PowerShell-скрипта: у контейнері немає ані
psql, ані PowerShell, а тягнути клієнт Postgres в образ заради одного
запуску — це половина дистрибутива на порожньому місці.

Міграції вшиті через embed і переїхали в server/migrations: embed не
бачить нічого за межами кореня свого модуля, а міграції поруч із
бінарником, який їх накочує, не можуть розійтися версіями.

Накочування під advisory-блокуванням: два інстанси при rolling update
інакше застосували б ту саму міграцію двічі. Кожен файл в одній
транзакції разом із записом у schema_migrations; виняток — continuous
aggregates, які TimescaleDB забороняє в транзакції. Змінена вже
застосована міграція зупиняє запуск: у різних інсталяціях інакше
опиниться різна схема під одним номером.

Перевірено на чистій базі: 23 міграції, 101 таблиця, повторний запуск
каже «схема актуальна».

Веб віддає сам API через embed: на self-hosted це прибирає з інструкції
встановлення цілий компонент. Три політики кешування — назавжди для
assets із хешем у імені, ніколи для index.html, коротко для решти.

Знайдено живим прогоном: невідомий шлях під /api/ віддавав 200 з
index.html, і клієнт падав на розборі HTML як JSON замість чесного 404.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 01:01:55 +03:00

112 lines
5.7 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 :: 0011_rls.sql
-- Ізоляція тенантів через Row Level Security.
-- API виставляє SET LOCAL app.tenant_id = '<uuid>' на кожній транзакції.
-- =====================================================================
-- Ролі: власник схеми (міграції) та робоча роль застосунку.
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'netpulse_app') THEN
CREATE ROLE netpulse_app NOLOGIN;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'netpulse_worker') THEN
-- Воркери (агрегація, білінг, retention) працюють поверх усіх тенантів
CREATE ROLE netpulse_worker NOLOGIN BYPASSRLS;
END IF;
END $$;
GRANT USAGE ON SCHEMA core, inv, topo, ts, ncm, alr, bill TO netpulse_app, netpulse_worker;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA core, inv, topo, ts, ncm, alr, bill
TO netpulse_app, netpulse_worker;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA core, inv, topo, ts, ncm, alr, bill
TO netpulse_app, netpulse_worker;
ALTER DEFAULT PRIVILEGES IN SCHEMA core, inv, topo, ts, ncm, alr, bill
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO netpulse_app, netpulse_worker;
-- ---------------------------------------------------------------------
-- Вмикаємо RLS на всіх звичайних таблицях, що мають колонку tenant_id.
--
-- ВИНЯТОК: усі hypertables. TimescaleDB (перевірено на 2.29.1) відмовляє:
-- "operation not supported on hypertables that have columnstore enabled"
-- Тобто RLS несумісний зі стисненням (columnstore/hypercore), а стиснення
-- увімкнене на всіх гарячих time-series таблицях. Виключаємо гіпертаблі
-- цілком — інакше набір захищених таблиць мовчки залежав би від того,
-- на які з них уже накотили compression policy.
--
-- Ізоляція для time-series забезпечується на рівні запитів:
-- * доступ лише через JOIN з inv.devices / inv.interfaces / ts.series,
-- які під RLS;
-- * обов'язковий предикат tenant_id у репозиторному шарі API.
-- ---------------------------------------------------------------------
DO $$
DECLARE
r record;
BEGIN
FOR r IN
SELECT c.oid::regclass AS tbl, n.nspname, c.relname
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
WHERE c.relkind = 'r'
AND n.nspname IN ('core','inv','topo','ts','ncm','alr','bill')
AND NOT EXISTS (
SELECT 1 FROM timescaledb_information.hypertables h
WHERE h.hypertable_schema = n.nspname
AND h.hypertable_name = c.relname
)
LOOP
EXECUTE format('ALTER TABLE %s ENABLE ROW LEVEL SECURITY', r.tbl);
EXECUTE format('ALTER TABLE %s FORCE ROW LEVEL SECURITY', r.tbl);
EXECUTE format($p$
CREATE POLICY tenant_isolation ON %s
USING (tenant_id = core.current_tenant())
WITH CHECK (tenant_id = core.current_tenant())
$p$, r.tbl);
END LOOP;
END $$;
-- core.tenants: тенант бачить лише сам себе
ALTER TABLE core.tenants ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_self ON core.tenants
USING (id = core.current_tenant());
-- core.users глобальні (один користувач може бути в кількох тенантах):
-- видимі лише ті, хто має membership у поточному тенанті.
ALTER TABLE core.users ENABLE ROW LEVEL SECURITY;
CREATE POLICY users_in_tenant ON core.users
USING (EXISTS (
SELECT 1 FROM core.memberships m
WHERE m.user_id = core.users.id AND m.tenant_id = core.current_tenant()
));
-- Довідники без tenant_id читають усі
ALTER TABLE core.plugins ENABLE ROW LEVEL SECURITY;
ALTER TABLE core.check_types ENABLE ROW LEVEL SECURITY;
ALTER TABLE core.permissions ENABLE ROW LEVEL SECURITY;
ALTER TABLE bill.plans ENABLE ROW LEVEL SECURITY;
ALTER TABLE bill.features ENABLE ROW LEVEL SECURITY;
CREATE POLICY read_all ON core.plugins FOR SELECT USING (true);
CREATE POLICY read_all ON core.check_types FOR SELECT USING (true);
CREATE POLICY read_all ON core.permissions FOR SELECT USING (true);
CREATE POLICY read_all ON bill.plans FOR SELECT USING (true);
CREATE POLICY read_all ON bill.features FOR SELECT USING (true);
-- core.roles: системні (tenant_id IS NULL) + власні
DROP POLICY IF EXISTS tenant_isolation ON core.roles;
CREATE POLICY roles_visible ON core.roles
USING (tenant_id IS NULL OR tenant_id = core.current_tenant())
WITH CHECK (tenant_id = core.current_tenant());
-- ncm.profiles: вбудовані профілі спільні для всіх
DROP POLICY IF EXISTS tenant_isolation ON ncm.profiles;
CREATE POLICY profiles_visible ON ncm.profiles
USING (tenant_id IS NULL OR tenant_id = core.current_tenant())
WITH CHECK (tenant_id = core.current_tenant());
-- ---------------------------------------------------------------------
-- Захист від забутого SET app.tenant_id:
-- core.current_tenant() повертає NULL → жоден рядок не пройде політику.
-- Це навмисно: "порожній результат" безпечніший за "усі тенанти".
-- ---------------------------------------------------------------------