Netpulse_SasS/server/migrations/0013_groups_login.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

219 lines
11 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 :: 0013_groups_login.sql
-- Вхід за логіном; групи користувачів і права на групи пристроїв
-- у моделі, знайомій за Zabbix.
-- =====================================================================
-- ---------------------------------------------------------------------
-- Логін
-- ---------------------------------------------------------------------
-- Email лишається — він потрібен для сповіщень і відновлення доступу, —
-- але входом більше не є. Причина проста: у мережевій інсталяції
-- половина облікових записів технічні (noc, monitoring, oncall) і
-- поштової скриньки не має взагалі.
ALTER TABLE core.users ADD COLUMN IF NOT EXISTS username citext;
-- Наявним користувачам логін виводимо з локальної частини пошти.
-- Збіги неминучі (admin@a.com і admin@b.com), тому додаємо суфікс за
-- порядком створення: мовчки злити два акаунти в один логін не можна.
WITH numbered AS (
SELECT id,
split_part(email::text, '@', 1) AS base,
row_number() OVER (
PARTITION BY split_part(email::text, '@', 1)
ORDER BY created_at, id
) AS n
FROM core.users
WHERE username IS NULL
)
UPDATE core.users u
SET username = CASE WHEN n = 1 THEN numbered.base
ELSE numbered.base || n::text END
FROM numbered
WHERE u.id = numbered.id;
ALTER TABLE core.users ALTER COLUMN username SET NOT NULL;
-- Email більше не обов'язковий: технічний акаунт може жити без нього.
ALTER TABLE core.users ALTER COLUMN email DROP NOT NULL;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'users_username_uniq'
) THEN
ALTER TABLE core.users ADD CONSTRAINT users_username_uniq UNIQUE (username);
END IF;
END $$;
-- Логін бере на себе роль ідентифікатора, тому й обмеження на нього
-- жорсткіші за поштові: жодних пробілів, крапок на краях і регістрових
-- сюрпризів (citext уже прибирає останнє).
ALTER TABLE core.users
ADD CONSTRAINT users_username_format
CHECK (username ~ '^[a-z0-9][a-z0-9._-]{1,62}[a-z0-9]$');
-- ---------------------------------------------------------------------
-- Групи користувачів
-- ---------------------------------------------------------------------
-- Ролі відповідають на питання «що людині вільно робити», групи — «над
-- якими пристроями». Два незалежні виміри, і змішувати їх в одному
-- списку прав не можна: інженер над однією філією та інженер над усією
-- мережею мають однакову роль і різний доступ.
CREATE TABLE IF NOT EXISTS core.user_groups (
id uuid PRIMARY KEY DEFAULT core.new_id(),
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
name text NOT NULL,
description text,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (tenant_id, name)
);
CREATE TABLE IF NOT EXISTS core.user_group_members (
group_id uuid NOT NULL REFERENCES core.user_groups(id) ON DELETE CASCADE,
user_id uuid NOT NULL REFERENCES core.users(id) ON DELETE CASCADE,
PRIMARY KEY (group_id, user_id)
);
CREATE INDEX IF NOT EXISTS user_group_members_user_idx
ON core.user_group_members (user_id);
-- ---------------------------------------------------------------------
-- Права на групи пристроїв
-- ---------------------------------------------------------------------
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'access_level') THEN
CREATE TYPE core.access_level AS ENUM ('deny','read','write');
END IF;
END $$;
CREATE TABLE IF NOT EXISTS core.group_permissions (
user_group_id uuid NOT NULL REFERENCES core.user_groups(id) ON DELETE CASCADE,
device_group_id uuid NOT NULL REFERENCES inv.device_groups(id) ON DELETE CASCADE,
level core.access_level NOT NULL DEFAULT 'read',
PRIMARY KEY (user_group_id, device_group_id)
);
-- ---------------------------------------------------------------------
-- Обчислення доступу
-- ---------------------------------------------------------------------
-- Рівень доступу людини до одного пристрою.
--
-- Три правила, і кожне має причину:
--
-- 1. Хто не входить у жодну групу — не обмежений групами взагалі.
-- Це свідомо не по-заббіксівськи. Там користувач без груп не бачить
-- нічого, і кожна нова інсталяція починається з питання «чому
-- порожньо». Тут звуження вмикається тоді, коли його справді
-- налаштували, а до того доступ визначає роль.
--
-- 2. Заборона перемагає дозвіл. Пристрій у двох групах, де одна дає
-- читання, а друга забороняє, лишається невидимим: інакше заборону
-- можна обійти, додавши об'єкт у будь-яку іншу групу.
--
-- 3. Серед дозволів виграє найширший. Читання в одній групі й запис у
-- другій дають запис — людині надали обидва, і слабший не є
-- обмеженням для сильнішого.
CREATE OR REPLACE FUNCTION core.device_access_level(p_user uuid, p_device uuid)
RETURNS core.access_level
LANGUAGE sql STABLE AS $$
WITH my_groups AS (
SELECT group_id FROM core.user_group_members WHERE user_id = p_user
),
perms AS (
SELECT gp.level
FROM core.group_permissions gp
JOIN my_groups g ON g.group_id = gp.user_group_id
JOIN inv.device_group_members m ON m.group_id = gp.device_group_id
WHERE m.device_id = p_device
)
SELECT CASE
-- Без груп — без звуження.
WHEN NOT EXISTS (SELECT 1 FROM my_groups) THEN 'write'::core.access_level
WHEN EXISTS (SELECT 1 FROM perms WHERE level = 'deny') THEN 'deny'::core.access_level
WHEN EXISTS (SELECT 1 FROM perms WHERE level = 'write') THEN 'write'::core.access_level
WHEN EXISTS (SELECT 1 FROM perms WHERE level = 'read') THEN 'read'::core.access_level
-- У групах є, але цього пристрою в них немає — значить, не його.
ELSE 'deny'::core.access_level
END;
$$;
-- Перелік доступних пристроїв одним набором.
--
-- Окрема функція, а не виклик попередньої в циклі: список пристроїв
-- читається на кожному екрані, і перевірка по одному перетворила б
-- звичайну вибірку на N запитів.
CREATE OR REPLACE FUNCTION core.accessible_devices(p_user uuid, p_tenant uuid, p_need core.access_level)
RETURNS TABLE (device_id uuid)
LANGUAGE sql STABLE AS $$
WITH my_groups AS (
SELECT group_id FROM core.user_group_members WHERE user_id = p_user
),
unrestricted AS (
SELECT NOT EXISTS (SELECT 1 FROM my_groups) AS yes
),
granted AS (
SELECT m.device_id,
bool_or(gp.level = 'deny') AS denied,
bool_or(gp.level = 'write') AS writable
FROM core.group_permissions gp
JOIN my_groups g ON g.group_id = gp.user_group_id
JOIN inv.device_group_members m ON m.group_id = gp.device_group_id
GROUP BY m.device_id
)
SELECT d.id
FROM inv.devices d
WHERE d.tenant_id = p_tenant
AND d.deleted_at IS NULL
AND (
(SELECT yes FROM unrestricted)
OR EXISTS (
SELECT 1 FROM granted
WHERE granted.device_id = d.id
AND NOT granted.denied
AND (p_need <> 'write' OR granted.writable)
)
);
$$;
-- ---------------------------------------------------------------------
-- RLS для нових таблиць
-- ---------------------------------------------------------------------
ALTER TABLE core.user_groups ENABLE ROW LEVEL SECURITY;
ALTER TABLE core.user_groups FORCE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_isolation ON core.user_groups;
CREATE POLICY tenant_isolation ON core.user_groups
USING (tenant_id = core.current_tenant())
WITH CHECK (tenant_id = core.current_tenant());
-- Членство й права прив'язані до групи, тому їхня ізоляція випливає з
-- ізоляції самої групи. Окремої колонки tenant_id тут немає навмисно:
-- вона могла б розійтися з групою й тихо створити дірку.
ALTER TABLE core.user_group_members ENABLE ROW LEVEL SECURITY;
ALTER TABLE core.user_group_members FORCE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_isolation ON core.user_group_members;
CREATE POLICY tenant_isolation ON core.user_group_members
USING (EXISTS (SELECT 1 FROM core.user_groups g
WHERE g.id = group_id AND g.tenant_id = core.current_tenant()))
WITH CHECK (EXISTS (SELECT 1 FROM core.user_groups g
WHERE g.id = group_id AND g.tenant_id = core.current_tenant()));
ALTER TABLE core.group_permissions ENABLE ROW LEVEL SECURITY;
ALTER TABLE core.group_permissions FORCE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_isolation ON core.group_permissions;
CREATE POLICY tenant_isolation ON core.group_permissions
USING (EXISTS (SELECT 1 FROM core.user_groups g
WHERE g.id = user_group_id AND g.tenant_id = core.current_tenant()))
WITH CHECK (EXISTS (SELECT 1 FROM core.user_groups g
WHERE g.id = user_group_id AND g.tenant_id = core.current_tenant()));
-- Шлях входу читає користувача до того, як тенант відомий, — так само,
-- як у 0012 для пошуку за поштою.
DROP POLICY IF EXISTS users_auth_lookup ON core.users;
CREATE POLICY users_auth_lookup ON core.users
FOR SELECT USING (core.current_tenant() IS NULL);