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>
112 lines
5.7 KiB
SQL
112 lines
5.7 KiB
SQL
-- =====================================================================
|
||
-- 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 → жоден рядок не пройде політику.
|
||
-- Це навмисно: "порожній результат" безпечніший за "усі тенанти".
|
||
-- ---------------------------------------------------------------------
|