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