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>
229 lines
10 KiB
PL/PgSQL
229 lines
10 KiB
PL/PgSQL
-- =====================================================================
|
||
-- 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);
|