Netpulse_SasS/server/migrations/0001_core.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

229 lines
10 KiB
PL/PgSQL
Raw 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 :: 0001_core.sql
-- Розширення, домени, мультитенантність, автентифікація, RBAC, аудит.
-- PostgreSQL 16+ / TimescaleDB 2.14+
-- =====================================================================
CREATE EXTENSION IF NOT EXISTS timescaledb;
CREATE EXTENSION IF NOT EXISTS pgcrypto; -- digest(), gen_random_bytes()
CREATE EXTENSION IF NOT EXISTS citext; -- регістронезалежні email/hostname
CREATE EXTENSION IF NOT EXISTS pg_trgm; -- пошук по інвентарю
CREATE EXTENSION IF NOT EXISTS btree_gist; -- EXCLUDE-обмеження (maintenance windows)
CREATE SCHEMA IF NOT EXISTS core;
CREATE SCHEMA IF NOT EXISTS inv; -- inventory
CREATE SCHEMA IF NOT EXISTS topo; -- топологія / мапи
CREATE SCHEMA IF NOT EXISTS ts; -- time-series (hypertables)
CREATE SCHEMA IF NOT EXISTS ncm; -- network config management
CREATE SCHEMA IF NOT EXISTS alr; -- alerting
CREATE SCHEMA IF NOT EXISTS bill; -- billing / licensing
-- ---------------------------------------------------------------------
-- Спільні домени й хелпери
-- ---------------------------------------------------------------------
-- Slug: [a-z0-9_-], 1..64, починається й закінчується літерою/цифрою.
-- Підкреслення дозволене — ключі фіч (http_checks, auto_discovery) його використовують.
CREATE DOMAIN core.slug AS text
CHECK (VALUE ~ '^[a-z0-9]([a-z0-9_-]{0,62}[a-z0-9])?$');
-- Обгортка для UUIDv7-подібних ключів. Поки що v4 (gen_random_uuid),
-- заміна на pg_uuidv7 не потребує зміни DDL.
CREATE OR REPLACE FUNCTION core.new_id() RETURNS uuid
LANGUAGE sql VOLATILE AS $$ SELECT gen_random_uuid() $$;
-- Поточний tenant із сесійної змінної (використовується в RLS).
CREATE OR REPLACE FUNCTION core.current_tenant() RETURNS uuid
LANGUAGE sql STABLE AS $$
SELECT NULLIF(current_setting('app.tenant_id', true), '')::uuid
$$;
CREATE OR REPLACE FUNCTION core.touch_updated_at() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
NEW.updated_at := now();
RETURN NEW;
END $$;
-- ---------------------------------------------------------------------
-- Tenants (організації)
-- ---------------------------------------------------------------------
CREATE TYPE core.tenant_status AS ENUM ('trial','active','past_due','suspended','cancelled');
CREATE TABLE core.tenants (
id uuid PRIMARY KEY DEFAULT core.new_id(),
slug core.slug NOT NULL UNIQUE,
name text NOT NULL,
status core.tenant_status NOT NULL DEFAULT 'trial',
-- White-label (Enterprise): logo_url, primary_color, custom_domain, favicon
branding jsonb NOT NULL DEFAULT '{}'::jsonb,
settings jsonb NOT NULL DEFAULT '{}'::jsonb,
timezone text NOT NULL DEFAULT 'UTC',
-- Ідентифікатор інсталяції для self-hosted (прив'язка ліцензійного ключа)
install_id uuid NOT NULL DEFAULT core.new_id(),
trial_ends_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
);
CREATE TRIGGER trg_tenants_touch BEFORE UPDATE ON core.tenants
FOR EACH ROW EXECUTE FUNCTION core.touch_updated_at();
-- ---------------------------------------------------------------------
-- Users / Membership / RBAC
-- ---------------------------------------------------------------------
CREATE TABLE core.users (
id uuid PRIMARY KEY DEFAULT core.new_id(),
email citext NOT NULL UNIQUE,
email_verified_at timestamptz,
password_hash text, -- argon2id; NULL для SSO-only
full_name text,
avatar_url text,
locale text NOT NULL DEFAULT 'uk',
timezone text NOT NULL DEFAULT 'UTC',
totp_secret_enc bytea, -- AES-GCM-256, див. core.secrets
mfa_enabled boolean NOT NULL DEFAULT false,
-- Прив'язка Telegram для Mini App / сповіщень
telegram_user_id bigint UNIQUE,
last_login_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
);
CREATE TRIGGER trg_users_touch BEFORE UPDATE ON core.users
FOR EACH ROW EXECUTE FUNCTION core.touch_updated_at();
-- Ролі: системні (tenant_id IS NULL) + кастомні на рівні тенанта (Enterprise)
CREATE TABLE core.roles (
id uuid PRIMARY KEY DEFAULT core.new_id(),
tenant_id uuid REFERENCES core.tenants(id) ON DELETE CASCADE,
key core.slug NOT NULL, -- owner, admin, engineer, viewer, ...
name text NOT NULL,
description text,
is_system boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now()
);
-- Один і той самий key унікальний в межах тенанта; системні — глобально.
CREATE UNIQUE INDEX roles_tenant_key_uniq
ON core.roles (COALESCE(tenant_id, '00000000-0000-0000-0000-000000000000'::uuid), key);
-- Права у вигляді "resource:action" (devices:read, maps:write, ncm:diff, billing:manage)
CREATE TABLE core.permissions (
key text PRIMARY KEY CHECK (key ~ '^[a-z_]+:[a-z_]+$'),
description text NOT NULL,
-- Плагін, який зареєстрував право (NULL = ядро)
plugin_key core.slug
);
CREATE TABLE core.role_permissions (
role_id uuid NOT NULL REFERENCES core.roles(id) ON DELETE CASCADE,
permission_key text NOT NULL REFERENCES core.permissions(key) ON DELETE CASCADE,
PRIMARY KEY (role_id, permission_key)
);
CREATE TABLE core.memberships (
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
user_id uuid NOT NULL REFERENCES core.users(id) ON DELETE CASCADE,
role_id uuid NOT NULL REFERENCES core.roles(id) ON DELETE RESTRICT,
-- Обмеження видимості: NULL = усі пристрої тенанта, інакше — перелік груп
scope_group_ids uuid[],
invited_by uuid REFERENCES core.users(id) ON DELETE SET NULL,
accepted_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (tenant_id, user_id)
);
CREATE INDEX memberships_user_idx ON core.memberships (user_id);
CREATE TABLE core.invitations (
id uuid PRIMARY KEY DEFAULT core.new_id(),
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
email citext NOT NULL,
role_id uuid NOT NULL REFERENCES core.roles(id) ON DELETE CASCADE,
token_hash bytea NOT NULL,
expires_at timestamptz NOT NULL,
accepted_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (tenant_id, email)
);
-- Сесії (refresh-токени) + API-ключі
CREATE TABLE core.sessions (
id uuid PRIMARY KEY DEFAULT core.new_id(),
user_id uuid NOT NULL REFERENCES core.users(id) ON DELETE CASCADE,
tenant_id uuid REFERENCES core.tenants(id) ON DELETE CASCADE,
token_hash bytea NOT NULL UNIQUE, -- sha256(refresh_token)
user_agent text,
ip inet,
expires_at timestamptz NOT NULL,
revoked_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX sessions_user_idx ON core.sessions (user_id) WHERE revoked_at IS NULL;
CREATE TABLE core.api_tokens (
id uuid PRIMARY KEY DEFAULT core.new_id(),
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
created_by uuid REFERENCES core.users(id) ON DELETE SET NULL,
name text NOT NULL,
prefix text NOT NULL, -- перші 8 символів для показу в UI
token_hash bytea NOT NULL UNIQUE,
scopes text[] NOT NULL DEFAULT '{}',
last_used_at timestamptz,
expires_at timestamptz,
revoked_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX api_tokens_tenant_idx ON core.api_tokens (tenant_id);
-- ---------------------------------------------------------------------
-- Сховище секретів (AES-GCM-256; шифрування на боці застосунку)
-- Жоден секрет не потрапляє в БД у відкритому вигляді.
-- ---------------------------------------------------------------------
CREATE TYPE core.secret_kind AS ENUM (
'ssh_password','ssh_key','snmp_v3','api_token','telnet','enable_password',
'webhook_secret','totp','generic'
);
CREATE TABLE core.secrets (
id uuid PRIMARY KEY DEFAULT core.new_id(),
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
kind core.secret_kind NOT NULL,
alg text NOT NULL DEFAULT 'AES-256-GCM',
key_id text NOT NULL, -- ідентифікатор DEK у KMS/Vault (ротація)
nonce bytea NOT NULL, -- 96-bit IV
ciphertext bytea NOT NULL,
auth_tag bytea NOT NULL, -- 128-bit GCM tag
aad text, -- зазвичай tenant_id||kind||object_id
version int NOT NULL DEFAULT 1,
created_at timestamptz NOT NULL DEFAULT now(),
rotated_at timestamptz
);
CREATE INDEX secrets_tenant_idx ON core.secrets (tenant_id, kind);
-- ---------------------------------------------------------------------
-- Аудит (hypertable — журнал росте лінійно)
-- ---------------------------------------------------------------------
CREATE TABLE core.audit_log (
ts timestamptz NOT NULL DEFAULT now(),
id uuid NOT NULL DEFAULT core.new_id(),
tenant_id uuid,
actor_user_id uuid,
actor_token_id uuid,
actor_ip inet,
action text NOT NULL, -- device.create, map.update, ncm.rollback
object_type text,
object_id uuid,
before jsonb,
after jsonb,
meta jsonb NOT NULL DEFAULT '{}'::jsonb,
PRIMARY KEY (ts, id)
);
SELECT create_hypertable('core.audit_log', 'ts',
chunk_time_interval => INTERVAL '7 days',
if_not_exists => TRUE);
CREATE INDEX audit_tenant_ts_idx ON core.audit_log (tenant_id, ts DESC);
CREATE INDEX audit_object_idx ON core.audit_log (object_type, object_id, ts DESC);