# NetPulse — схема бази даних (Етап 1) PostgreSQL 16+ / TimescaleDB 2.14+. Міграції в `migrations/`, накочуються по порядку номерів. ```bash docker compose up -d db ``` ```bash pwsh ./db/migrate.ps1 ``` ## Схеми (namespaces) | Схема | Призначення | |--------|-------------| | `core` | Ядро: тенанти, users, RBAC, секрети, агенти, плагіни, чеки, дашборди, аудит | | `inv` | Інвентар: сайти, групи, пристрої, інтерфейси, IP, підмережі, креденшели | | `topo` | Топологія: сирі сусіди (LLDP/CDP/ARP), зведені лінки, мапи, вузли, ребра, підкладки | | `ts` | Time-series: hypertables + continuous aggregates | | `ncm` | Config management: репо, профілі, задачі, версії конфігів, diff, compliance | | `alr` | Alerting: правила, алерти, канали, ескалації, вікна обслуговування | | `bill` | Тарифи, підписки, entitlements, usage, інвойси, ліцензійні ключі | ## Порядок міграцій | # | Файл | Що містить | |---|------|-----------| | 0001 | `0001_core.sql` | Розширення, домени, tenants, users, RBAC, секрети (AES-GCM), audit_log | | 0002 | `0002_inventory.sql` | Sites, groups, devices, interfaces, addresses, subnets, credentials | | 0003 | `0003_agents_plugins.sql` | Реєстр плагінів, агенти, типи чеків, checks, event outbox | | 0004 | `0004_topology.sql` | **Neighbors → links → maps → nodes/edges → backgrounds → revisions** | | 0005 | `0005_telemetry_timescale.sql` | Hypertables, CAGG, compression, retention, view'и для мапи | | 0006 | `0006_ncm.sql` | Git-репо, профілі збору, jobs, configs, diffs, compliance, rollback | | 0007 | `0007_alerting.sql` | Rules, alerts, channels, push, routes, maintenance, mutes | | 0008 | `0008_dashboards.sql` | Дашборди, віджети (в т.ч. `map`), saved views, SLA | | 0009 | `0009_billing_licensing.sql` | Plans, subscriptions, entitlements, usage, invoices, license keys | | 0010 | `0010_seed.sql` | Довідники: права, ролі, плагіни, чеки, тарифи, NCM-профілі | | 0011 | `0011_rls.sql` | Row Level Security + ролі БД | > 0010 навмисно йде **перед** 0011: після `FORCE ROW LEVEL SECURITY` навіть власник схеми не зможе вставити довідникові рядки з `tenant_id IS NULL`. ## ERD: ядро топології ```mermaid erDiagram TENANTS ||--o{ DEVICES : "" TENANTS ||--o{ MAPS : "" SITES ||--o{ DEVICES : "" DEVICES ||--o{ INTERFACES : "" DEVICES ||--o{ NEIGHBORS : "виявлено з" NEIGHBORS }o--|| LINKS : "резолвиться в" INTERFACES ||--o{ LINKS : "a_if / b_if" MAPS ||--o{ MAP_NODES : "" MAPS ||--o{ MAP_EDGES : "" MAPS ||--o{ MAP_BACKGROUNDS : "floor plan / OSM / rack" MAPS ||--o{ MAP_REVISIONS : "undo/redo" DEVICES ||--o{ MAP_NODES : "kind='device'" MAP_NODES ||--o{ MAP_EDGES : "source/target" LINKS ||--o{ MAP_EDGES : "статус і трафік" INTERFACES ||--o{ IF_COUNTERS : "трафік → анімація" DEVICES ||--o{ ICMP_SAMPLES : "колір вузла" ``` ## Ключові рішення **1. Фізична топологія відокремлена від візуальної.** `topo.links` — те, що існує в мережі (виявлено LLDP/CDP або створено вручну). `topo.map_edges` — як це намальовано на конкретній мапі. Один лінк може бути показаний на N мапах з різними стилями; видалення мапи не чіпає топологію. `map_edges.link_id` — джерело статусу й трафіку для лінії. **2. Порт-у-порт зв'язки.** І `topo.links`, і `topo.map_edges` посилаються на `inv.interfaces`, тому `Switch1:Port1 → Router1:eth0` — це FK, а не текст. Унікальний індекс `links_pair_uniq` нормалізує пару через `LEAST/GREATEST`, щоб A→B і B→A не дублювались. **3. Автовиявлення не затирає ручну роботу.** `topo.neighbors` зберігає сире, як його віддав агент, плюс `resolved_*` і `confidence`. Резолвер зводить це в `topo.links`. Прапорець `is_pinned` захищає підтверджені людиною лінки від перезапису. **4. Дві моделі метрик.** - `ts.series` + `ts.samples` — узагальнена (Prometheus-подібна): будь-який плагін реєструє свій `metric_key` без зміни DDL. - `ts.icmp_samples` і `ts.if_counters` — широкі таблиці для гарячих шляхів. Це те, що читає мапа на кожному тику WebSocket, і денормалізація тут окупається. **5. Анімація трафіку має конкретне джерело.** `if_counters.util_out_pct` (% від `interfaces.speed_bps`) → `topo.link_live` (гірша з двох сторін) → `map_edges.animation.speed_source='utilization'`. Кольори порогів — у `map_edges.thresholds`. **6. Продуктивність мапи на 1000+ вузлів.** `map_nodes` має GiST-індекс по `point(x,y)` для viewport-culling, `map_edges` — індекси по source/target. Повний стан полотна тягнеться одним запитом (nodes + edges + backgrounds + останні статуси через `ts.device_last_icmp` і `topo.link_live`), далі дельти йдуть по WebSocket. **7. Ліміти тарифу перевіряються двічі.** `bill.entitlements` — матеріалізовані права тенанта (читає API на кожному запиті, кешується в Redis). Плюс тригери `bill.assert_device_limit()` і `bill.assert_map_node_limit()` як друга лінія оборони на рівні БД. **8. Секрети ніколи не лежать відкрито.** `core.secrets` зберігає лише `ciphertext` + `nonce` + `auth_tag` + `key_id` (AES-GCM-256, шифрування на боці застосунку, DEK у KMS/Vault). Паролі SSH/SNMPv3, приватні ключі, токени каналів і тіла конфігів — усе через цю таблицю. **9. Тіло конфігів — у Git, метадані — у БД.** `ncm.configs` тримає `commit_sha`/`blob_sha`/`path` та `content_hash` для швидкої відповіді «змінилось?». Diff-и кешуються в `ncm.diffs`, щоб Telegram Mini App не рахував їх щоразу. ## Ізоляція тенантів API виставляє на кожній транзакції: ```sql SET LOCAL app.tenant_id = ''; ``` Політика `tenant_isolation` (USING + WITH CHECK) вмикається автоматично на кожній таблиці з колонкою `tenant_id`. Якщо змінна не виставлена — `core.current_tenant()` повертає NULL і запит дає порожній результат: це навмисно безпечніше за «усі тенанти». **Свідомі винятки, які треба тримати в голові:** - **Усі 12 hypertables** — RLS вимкнено. TimescaleDB відмовляє: `operation not supported on hypertables that have columnstore enabled`, тобто RLS несумісний зі стисненням, а стиснення увімкнене на всіх гарячих time-series таблицях. Виключено гіпертаблі цілком, а не вибірково — інакше набір захищених таблиць мовчки залежав би від того, на які з них уже накотили compression policy. Ізоляція для них: доступ лише через JOIN з `inv.devices` / `inv.interfaces` / `ts.series` (усі під RLS) плюс обов'язковий предикат `tenant_id` у репозиторному шарі API. - Зв'язкові таблиці без власного `tenant_id` (`core.role_permissions`, `inv.device_group_members`, `inv.device_tags`, `inv.device_credentials`, `bill.invoice_lines`, `topo.map_shares`) покладаються на FK-каскад від захищеного батька. - `netpulse_worker` має `BYPASSRLS` — воркери агрегації, білінгу й retention працюють поверх тенантів. ## Стан перевірки Схема прогнана на живому стенді: **Debian 13 / PostgreSQL 17.11 / TimescaleDB 2.29.1**. Усі 11 міграцій накотились на чисту БД без помилок. | Об'єкт | Кількість | |--------|-----------| | Таблиць | 81 | | Hypertables | 12 | | Continuous aggregates | 6 | | Фонових job-ів (compression/retention/refresh) | 25 | | RLS-політик | 63 | | Індексів | 220 | `tests/smoke.sql` перевіряє не лише синтаксис, а й поведінку моделі: ```bash psql -d netpulse -f db/tests/smoke.sql ``` | Перевірка | Результат | |-----------|-----------| | Дзеркальний лінк B→A відхилено (`links_pair_uniq`) | PASS | | `kind='device'` без `device_id` відхилено (`map_nodes_kind_ref_chk`) | PASS | | Дубль активного алерту відхилено (`alerts_active_dedup_uniq`) | PASS | | 16-й пристрій на тарифі Free відхилено тригером | PASS | | Стан полотна мапи одним запитом (вузли + статуси + RTT) | PASS | | Ребро з портами `Gi0/1 → ether1` і живим `util_pct` = 78% | PASS | | CAGG `if_counters_5m` рахує роллапи | PASS | | RLS: ACME бачить 15 пристроїв, Globex — 0, без `app.tenant_id` — 0 | PASS |