Netpulse_SasS/server/migrations/0069_billing.sql
byrsapty ca143a616b
Some checks failed
CI / hygiene (push) Successful in 8s
CI / web (push) Successful in 59s
CI / server (push) Failing after 3m27s
CI / agent (push) Successful in 3m3s
Білінг, SLA, вбудовані правила, пісочниця установника, тести сторінок
П'ять паралельних задач. Найцінніше в них — не можливості, а знайдене.

0069 БІЛІНГ. Аудит 0009 показав, що перевірка ліміту не спрацювала б
жодного разу: isPlanLimit шукала слово «ліміт», а тригер писав
"device limit reached" англійською. Перше ж досягнення стелі дало б
клієнту 500 замість пояснення. Плюс три діри: тригер лише на INSERT
(стеля в 15 обходилась за чотири дії через архів), max_maps/max_agents/
max_users не перевіряло ніщо — тобто рівно те, чим відрізняються плани,
і license_keys була закрита політикою tenant_isolation з 0011, хоча
tenant_id там NULLABLE навмисно: головний сценарій self-hosted був
недосяжний.

Після закінчення ліцензії не вимикається нічого — замерзає лише ріст.
Моніторинг, що перестав моніторити через несплачений рахунок, це
аварія в мережі клієнта, спричинена нами.

0070 SLA. Джерелом обрано ts.icmp_1h, а не device_status_history:
остання не вміє сказати «ми не знали» — перехід пишеться лише при
зміні стану, тож доба мовчання зонда виглядає як доба роботи. Час
розкладено на чотири частини, і «немає даних» не додається ні до чого;
замість вибору між двома брехнями звіт каже, яку частку періоду він
бачив. Закритий період тримає тригер, а не домовленість у Go.

0071 ВІДПОВІДНІСТЬ. 20 правил, кожне прив'язане до родини: об'єднаний
вираз, що покриває Cisco й не покриває MikroTik, дав би «0 порушень» і
сховав сліпу пляму. Вендор не входить у перелік, доки для нього немає
зразка конфігу в тесті. TestBuiltinRulesAreNotAlwaysGreen вимагає, щоб
у кожного правила був конфіг, де воно спрацювало, І де ні.

ПІСОЧНИЦЯ УСТАНОВНИКА — та сама установка в ізольованому проєкті
compose. Знайшла дві справжні вади з трьох спроб:
  * healthcheck бази ходив unix-сокетом, а споживачі по TCP. При
    первинній ініціалізації Postgres слухає лише сокет — compose
    вважав базу здоровою, migrate отримував connection refused. На
    створеній базі цієї фази немає, тож вада чекала на першого клієнта;
  * у білому переліку модулів API не було traps і filecfg — зонд із
    приймачем трапів неможливо було зареєструвати взагалі.

ТЕСТИ СТОРІНОК: 137 → 252. Мережевий шар, права доступу, незворотні
дії, фільтри з адресного рядка. Підмінюється лише fetch і WebSocket —
api/client.ts працює справжній.
2026-08-27 21:17:23 +03:00

641 lines
42 KiB
PL/PgSQL
Raw Permalink 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 :: 0069_billing.sql
-- Тарифи, ліміти й ліцензії: те, що 0009 описала, але чим ніхто ніколи
-- не скористався.
--
-- ЩО З 0009 ЖИВЕ, А ЩО ЛЕЖИТЬ МЕРТВИМ
--
-- Це перше, що треба знати, бо будувати поверх 0009 без цієї звірки
-- означає добудовувати те, чого немає. Звірка зроблена grep-ом по
-- всьому дереву: `bill.` згадується поза самою 0009 рівно в семи
-- місцях, і жодне з них не є кодом застосунку.
--
-- ЖИВЕ рівно три речі:
--
-- 1. bill.plans і bill.features — заповнені 0010 (три плани,
-- дев'ятнадцять фіч), читаються політикою read_all з 0011. Тобто
-- дані є й видимі. Жоден рядок Go їх не читає.
--
-- 2. Тригери bill.assert_device_limit і bill.assert_map_node_limit —
-- справді висять на INSERT і справді виконуються на КОЖНІЙ вставці
-- хоста й вузла мапи. Але перший їхній рядок — SELECT max_devices
-- FROM bill.entitlements, а bill.entitlements порожня на кожній
-- інсталяції, що існує: заповнює її лише db/tests/smoke.sql.
-- lim IS NULL → RETURN NEW. Тобто механізм працює, а ефекту не має
-- ніде, крім смоук-тесту.
--
-- 3. store/maps_write.go::mapPgError перекладає HINT='upgrade_plan' у
-- ErrPlanLimit, а httpapi віддає 402. Це єдина справді робоча
-- ланка — і вона є лише на шляху правки мапи.
--
-- МЕРТВЕусе інше. bill.subscriptions, bill.entitlements (як місце,
-- куди хтось пише), bill.usage_daily, bill.usage_reports, bill.invoices,
-- bill.invoice_lines, bill.payment_events, bill.license_keys,
-- bill.license_checkins, усі чотири ENUM-и. Жодного INSERT, жодного
-- SELECT з коду. Права billing:read і billing:manage заведені 0010 і
-- перелічені в store/roles.go у dormantPerms як «сторінки тарифу ще
-- немає».
--
-- І одна ланка ЗЛАМАНА, що гірше за мертву. httpapi/groups.go на
-- створенні хоста робить isPlanLimit(err) — а та перевіряє
-- strings.Contains(err.Error(), "ліміт"). Тригер 0009 підіймає
-- 'device limit reached for tenant % (limit %)', тобто англійською.
-- Збігу немає ніколи. Отже в мить, коли ліміт УПЕРШЕ спрацював би,
-- людина отримала б не «вичерпано ліміт тарифу», а 500 «внутрішня
-- помилка» — рівно те, чого перевірка в БД мала не допустити.
--
-- ДЕ ЛІМІТ НЕ СПРАЦЬОВУЄ, ХОЧА МАВ БИ
--
-- Це небезпечніший бік, ніж хибне спрацювання: хибне видно одразу й
-- скаржаться на нього того ж дня, а пропущене не проявляється ніяк.
--
-- Тригер стоїть лише на INSERT. Хост, повернутий з архіву
-- (RestoreDevices — UPDATE deleted_at = NULL), і хост, який просто
-- ввімкнули (UPDATE enabled = true), проходять повз перевірку цілком.
-- Тобто стелю в 15 хостів обходить будь-хто: завести 15, заархівувати
-- десять, завести ще десять, повернути з архіву. Отримуємо 25 під
-- наглядом і план, який каже «до 15».
--
-- max_maps, max_agents, max_users і metric_retention_days з
-- bill.plans не перевіряє НІЩО й ніде. Це не колонки про запас — це
-- те, чим три плани в 0010 відрізняються один від одного.
--
-- ЩО РОБИТЬ ЦЯ МІГРАЦІЯ
--
-- 1. Заводить bill.entitlements КОЖНОМУ наявному кабінету — але з
-- планом, у якого всі стелі порожні. Пояснення нижче; коротко:
-- оновлення не має права нічого відібрати.
-- 2. Переписує перевірку лімітів: одна функція замість двох, робота
-- на INSERT і на UPDATE, повідомлення українською й машиночитна
-- подробиця, з якої застосунок збирає фразу «у тарифі Х дозволено
-- N хостів, зараз N».
-- 3. Заводить стан ліцензії на рівні інсталяції (bill.instance):
-- install_id, сам ключ, перевірений payload, строк, пільговий
-- період і монотонний годинник.
-- 4. Відкриває bill.license_keys для ключів, не прив'язаних до
-- кабінету, — під RLS з 0011 такий рядок не видно нікому взагалі.
--
-- ЧОГО ЦЯ МІГРАЦІЯ НЕ РОБИТЬ
--
-- Нічого зі Stripe. Таблиці 0009 (subscriptions, invoices,
-- payment_events, usage_reports) лишаються як є й лишаються порожніми.
-- Це свідомо: платіжка не має права протікати в перевірку лімітів.
-- Єдине, що читають тригери й застосунок, — bill.entitlements; хто саме
-- її заповнив (ліцензійний ключ, Stripe, домовленість руками), видно в
-- колонці source й нікого більше не обходить. Тому інтеграцію з
-- платіжкою можна дописати пізніше, не торкаючись жодного рядка нижче.
-- =====================================================================
-- ---------------------------------------------------------------------
-- 1. План, з якого нічого не ламається
-- ---------------------------------------------------------------------
-- Найнебезпечніший рядок усієї задачі — той, яким наявним кабінетам
-- уперше видають entitlements. Досі стеля не діяла НІДЕ; будь-яке
-- значення, крім «немає стелі», означає, що після накочування цієї
-- міграції інсталяція з двомастами хостами перестає приймати
-- двісті перший. Тобто оновлення, яке нічого не питало, відібрало б
-- у клієнта продукт — і виявилось би це не тут, а вночі, коли черговий
-- заводить хост після аварійної заміни.
--
-- Тому план self_hosted: усі стелі NULL, усі фічі. Він не «безкоштовний
-- enterprise» — він СТАН «ліміти ще ніхто не задавав». Стелі з'являються
-- рівно тоді, коли їх задає свідома дія: застосований ліцензійний ключ
-- або обраний тариф. Це та сама логіка, що й у 0064 зі строками
-- зберігання: оновлення вмикає механізм, але не вмикає його наслідків.
--
-- is_public = false: у переліку тарифів на сторінці його немає, бо
-- купити його не можна. Він показується лише як поточний стан.
INSERT INTO bill.plans
(key, name, description, base_price_cents, per_device_cents,
max_devices, max_maps, max_map_nodes, max_agents, max_users,
metric_retention_days, features, sort_order, is_public)
SELECT 'self_hosted', 'Self-hosted без ліцензії',
'Стелі не задані. Так виглядає інсталяція, якій ще не застосували ключ і не обрали тариф',
0, 0, NULL, NULL, NULL, NULL, NULL, 400,
-- Набір фіч береться з enterprise, а не переписується списком:
-- список розійшовся б із 0010 на першій же новій фічі, і
-- розбіжність побачив би лише той, хто відкриє обидва файли.
(SELECT features FROM bill.plans WHERE key = 'enterprise'),
0, false
ON CONFLICT (key) DO UPDATE SET
features = EXCLUDED.features,
is_public = false;
COMMENT ON COLUMN bill.plans.is_public IS
'Чи показувати в переліку тарифів. false — стан, а не пропозиція (self_hosted)';
-- ---------------------------------------------------------------------
-- 2. Entitlements кожному кабінету
-- ---------------------------------------------------------------------
-- Матеріалізована таблиця, а не VIEW поверх plans і subscriptions, — і
-- це рішення 0009, яке варто підтвердити вголос, бо на нього спирається
-- усе решта. Причина в тому, ЗВІДКИ стелі можуть узятись: із тарифу, з
-- персональних домовленостей (subscriptions.overrides), з ліцензійного
-- ключа, з пільгового періоду після прострочення. Вигляд, який зводить
-- чотири джерела, довелось би обчислювати в тригері на кожній вставці
-- хоста — тобто платити JOIN-ом по чотирьох таблицях за кожен рядок
-- автовиявлення.
--
-- Ціна матеріалізації — розсинхрон: рядок може відстати від того, що
-- насправді дає ліцензія. Тому перерахунок робить рівно одне місце
-- (store/billing_license.go, ApplyLicense і годинний такт), а не кожен,
-- кому знадобилось.
-- Вставка йде по одному кабінету з виставленим app.tenant_id, а не
-- одним INSERT ... SELECT, і це не стилістика. 0011 повісила на
-- bill.entitlements політику tenant_isolation разом із FORCE ROW LEVEL
-- SECURITY — тобто WITH CHECK (tenant_id = core.current_tenant())
-- перевіряється й для власника таблиці. Без контексту
-- core.current_tenant() дає NULL, порівняння дає NULL, і жоден рядок не
-- проходить. Один INSERT спрацював би лише під суперкористувачем; те,
-- що міграції сьогодні котять саме ним, — властивість розгортання, а не
-- гарантія (0063 якраз забирає BYPASSRLS у робочих ролей). Цикл працює
-- під будь-якою роллю, що має право писати в таблицю.
DO $$
DECLARE
t record;
BEGIN
FOR t IN SELECT id FROM core.tenants WHERE deleted_at IS NULL LOOP
PERFORM set_config('app.tenant_id', t.id::text, true);
INSERT INTO bill.entitlements
(tenant_id, plan_key, max_devices, max_maps, max_map_nodes, max_agents,
max_users, metric_retention_days, features, source)
SELECT t.id, p.key, p.max_devices, p.max_maps, p.max_map_nodes, p.max_agents,
p.max_users, p.metric_retention_days, p.features, 'license_key'
FROM bill.plans p
WHERE p.key = 'self_hosted'
ON CONFLICT (tenant_id) DO NOTHING;
END LOOP;
PERFORM set_config('app.tenant_id', '', true);
END $$;
-- Причина останнього перерахунку — колонка про людей, а не про машину.
--
-- «Чому в мене раптом стеля 15 хостів» — питання, на яке без цього
-- рядка немає відповіді взагалі: entitlements переписується цілком, і
-- попереднього стану ніде не лишається. Значення тут коротке й
-- перелічуване, бо його читає інтерфейс: license (застосували ключ),
-- license_expired (ключ протермінувався), plan (обрали тариф),
-- migration (заведено оновленням), manual (руками в базі).
ALTER TABLE bill.entitlements
ADD COLUMN IF NOT EXISTS reason text NOT NULL DEFAULT 'migration',
ADD COLUMN IF NOT EXISTS license_id uuid;
COMMENT ON COLUMN bill.entitlements.reason IS
'Чому стелі саме такі: license | license_expired | plan | migration | manual';
COMMENT ON COLUMN bill.entitlements.grace_until IS
'Кінець пільгового періоду. Після нього стеля замерзає на досягнутому, а не падає';
-- ---------------------------------------------------------------------
-- 3. Перевірка лімітів у БД
-- ---------------------------------------------------------------------
-- ЧОМУ ПЕРЕВІРКА ЛИШАЄТЬСЯ В БАЗІ, А НЕ ПЕРЕЇЖДЖАЄ В GO
--
-- Спокуса саме така: «ліміт має відмовляти зрозуміло, отже хай його
-- рахує застосунок і сам пише фразу». Це помилка, і вона коштує рівно
-- того, заради чого ліміт існує.
--
-- Порахувати в Go означає SELECT count(*), потім INSERT — тобто вікно
-- між ними. Два браузери, два запити автовиявлення, масова вставка з
-- імпорту — і обидва бачать «14 з 15», обидва вставляють. Стеля з
-- гонкою — це не стеля.
--
-- Тому перевірка лишається там, де вона й має бути: у тій самій
-- транзакції, що й вставка, під тим самим рядковим замком. А зрозумілу
-- фразу дає не місце перевірки, а те, ЩО саме вона підіймає нагору.
-- Досі вона підіймала англійський рядок без жодних чисел — звідси й
-- 500 замість 402.
-- bill.usage_now — скільки чого зайнято ЗАРАЗ.
--
-- Одна функція, а не count(*) по місцях виклику, з тієї ж причини, з
-- якої строки зберігання зведені в одну таблицю: «що вважається
-- зайнятим хостом» — це рішення, і воно має бути записане один раз.
-- Тут воно таке: хост займає слот, якщо він не в архіві І ввімкнений.
-- Вимкнений хост не опитується, не породжує метрик і не коштує нам
-- нічого — брати за нього гроші означало б брати за рядок у таблиці.
--
-- STABLE, а не VOLATILE: у межах одного запиту відповідь не міняється,
-- і планувальник має право не викликати її двічі.
-- Імена вихідних колонок із суфіксом, а не devices/maps/agents/users.
-- У функції на SQL імена вихідних параметрів підставляються в тіло як
-- ідентифікатори, і колонка з іменем `devices` поруч із таблицею
-- inv.devices — це рівно та неоднозначність, яку неприємно ловити на
-- накочуванні. Суфікс коштує нічого й прибирає питання цілком.
CREATE OR REPLACE FUNCTION bill.usage_now(p_tenant uuid)
RETURNS TABLE (devices_used int, maps_used int, agents_used int, users_used int)
LANGUAGE sql STABLE AS $$
SELECT
(SELECT count(*)::int FROM inv.devices
WHERE tenant_id = p_tenant AND deleted_at IS NULL AND enabled),
(SELECT count(*)::int FROM topo.maps
WHERE tenant_id = p_tenant AND deleted_at IS NULL),
-- Зонди без deleted_at: у core.agents архіву немає, видалення там
-- одразу справжнє (store/agents.go). Тому й умови «не в архіві» тут
-- немає — не забули, а нема чого писати.
(SELECT count(*)::int FROM core.agents
WHERE tenant_id = p_tenant),
(SELECT count(*)::int FROM core.memberships
WHERE tenant_id = p_tenant)
$$;
COMMENT ON FUNCTION bill.usage_now(uuid) IS
'Скільки слотів тарифу зайнято зараз. Одне визначення «зайнятого» на весь продукт';
-- bill.deny_limit — єдине місце, де перевірка перетворюється на відмову.
--
-- Дві частини повідомлення роблять різну роботу, і плутати їх не можна.
--
-- MESSAGE — фраза для людини, українською, з числами: «у тарифі
-- Free дозволено 15 хостів, зараз 15». Вона потрапляє в лог
-- Postgres, у psql, у будь-яку утиліту — тобто в усі місця, куди
-- застосунок не дотягнеться. Англійський рядок 0009 у цих місцях
-- читав лише розробник.
--
-- DETAIL — той самий факт у JSON, для застосунку. Розбирати MESSAGE
-- регулярками не можна: фразу колись перепишуть, і перевірка тихо
-- перестане впізнавати власну помилку — рівно те, що вже сталося з
-- isPlanLimit і словом «ліміт».
--
-- HINT лишається 'upgrade_plan' незмінним: за ним уже впізнає ліміт
-- store/maps_write.go::mapPgError, і ламати робочу ланку заради
-- однаковості нема причин.
CREATE OR REPLACE FUNCTION bill.deny_limit(
p_kind text, p_plan text, p_allowed int, p_used int)
RETURNS void LANGUAGE plpgsql AS $$
DECLARE
plan_name text;
noun text;
BEGIN
SELECT name INTO plan_name FROM bill.plans WHERE key = p_plan;
plan_name := COALESCE(plan_name, p_plan);
noun := CASE p_kind
WHEN 'devices' THEN 'хостів'
WHEN 'maps' THEN 'мап'
WHEN 'map_nodes' THEN 'вузлів на мапі'
WHEN 'agents' THEN 'зондів'
WHEN 'users' THEN 'користувачів'
ELSE p_kind
END;
RAISE EXCEPTION 'у тарифі % дозволено % %, зараз %', plan_name, p_allowed, noun, p_used
USING ERRCODE = 'check_violation',
HINT = 'upgrade_plan',
DETAIL = json_build_object(
'limit', p_kind,
'plan', p_plan,
'allowed', p_allowed,
'used', p_used)::text;
END $$;
-- Хости. Тепер і на UPDATE — саме там була дірка.
--
-- Умова спрацювання на UPDATE вужча за «будь-яка правка»: слот
-- займається лише переходом у стан «під наглядом». Перейменування
-- хоста, зміна адреси, прив'язка до зонда стелі не торкаються, і
-- перевіряти їх означало б рахувати count(*) на кожному такті збору,
-- який пише status.
--
-- Порахований count(*) не включає сам рядок, що правиться: BEFORE
-- UPDATE бачить таблицю зі СТАРИМИ значеннями, а старі — це «вимкнений»
-- або «в архіві», тобто під умову підрахунку рядок не підпадає. Тому
-- порівняння cnt >= lim правильне для обох операцій без окремої гілки.
CREATE OR REPLACE FUNCTION bill.assert_device_limit() RETURNS trigger
LANGUAGE plpgsql AS $$
DECLARE
lim int;
pkey text;
cnt int;
BEGIN
-- Вимкнений або одразу заархівований хост слота не займає. Вихід тут,
-- а не в кінці: інакше вимкнений хост не можна було б завести на
-- інсталяції під стелею — а саме так заводять хост «про запас» перед
-- переїздом, і саме це має лишатись можливим.
IF NOT NEW.enabled OR NEW.deleted_at IS NOT NULL THEN
RETURN NEW;
END IF;
-- Вкладений IF, а не один вираз через AND, і це не стиль. plpgsql
-- обчислює умову цілим виразом; `TG_OP = 'UPDATE' AND OLD.enabled`
-- на INSERT упало б на другій половині — «record old is not assigned
-- yet», — тобто перша ж вставка хоста поламала б продукт.
IF TG_OP = 'UPDATE' THEN
IF OLD.enabled AND OLD.deleted_at IS NULL THEN
RETURN NEW; -- слот уже був зайнятий цим самим рядком
END IF;
END IF;
SELECT max_devices, plan_key INTO lim, pkey
FROM bill.entitlements WHERE tenant_id = NEW.tenant_id;
IF lim IS NULL THEN
RETURN NEW; -- стелі немає або entitlements ще не заведено
END IF;
SELECT devices_used INTO cnt FROM bill.usage_now(NEW.tenant_id);
IF cnt >= lim THEN
PERFORM bill.deny_limit('devices', pkey, lim, cnt);
END IF;
RETURN NEW;
END $$;
DROP TRIGGER IF EXISTS trg_devices_limit ON inv.devices;
CREATE TRIGGER trg_devices_limit BEFORE INSERT OR UPDATE OF enabled, deleted_at
ON inv.devices
FOR EACH ROW EXECUTE FUNCTION bill.assert_device_limit();
-- Вузли мапи. Функція з 0009 лишається за змістом (стеля на ОДНУ мапу,
-- як і описано в 0010: «Free — 1 мапа, до 15 вузлів»), міняється лише
-- те, що вона підіймає нагору.
CREATE OR REPLACE FUNCTION bill.assert_map_node_limit() RETURNS trigger
LANGUAGE plpgsql AS $$
DECLARE
lim int;
pkey text;
cnt int;
BEGIN
SELECT max_map_nodes, plan_key INTO lim, pkey
FROM bill.entitlements WHERE tenant_id = NEW.tenant_id;
IF lim IS NULL THEN
RETURN NEW;
END IF;
SELECT count(*)::int INTO cnt FROM topo.map_nodes WHERE map_id = NEW.map_id;
IF cnt >= lim THEN
PERFORM bill.deny_limit('map_nodes', pkey, lim, cnt);
END IF;
RETURN NEW;
END $$;
-- Мапи, зонди й користувачі: колонки в bill.plans були з 0009, стелі не
-- було ніде. Тарифи, що відрізняються лише невиконуваними числами, —
-- це не тарифи, а таблиця.
--
-- Одна функція на три таблиці, а не три однакові: різниця між ними
-- вміщається в аргумент тригера, а три копії розійшлись би на першій же
-- правці підрахунку.
CREATE OR REPLACE FUNCTION bill.assert_tenant_limit() RETURNS trigger
LANGUAGE plpgsql AS $$
DECLARE
kind text := TG_ARGV[0];
lim int;
pkey text;
u record;
cnt int;
BEGIN
SELECT plan_key,
CASE kind
WHEN 'maps' THEN max_maps
WHEN 'agents' THEN max_agents
WHEN 'users' THEN max_users
END
INTO pkey, lim
FROM bill.entitlements WHERE tenant_id = NEW.tenant_id;
IF lim IS NULL THEN
RETURN NEW;
END IF;
SELECT * INTO u FROM bill.usage_now(NEW.tenant_id);
cnt := CASE kind
WHEN 'maps' THEN u.maps_used
WHEN 'agents' THEN u.agents_used
WHEN 'users' THEN u.users_used
END;
IF cnt >= lim THEN
PERFORM bill.deny_limit(kind, pkey, lim, cnt);
END IF;
RETURN NEW;
END $$;
DROP TRIGGER IF EXISTS trg_maps_limit ON topo.maps;
CREATE TRIGGER trg_maps_limit BEFORE INSERT ON topo.maps
FOR EACH ROW EXECUTE FUNCTION bill.assert_tenant_limit('maps');
DROP TRIGGER IF EXISTS trg_agents_limit ON core.agents;
CREATE TRIGGER trg_agents_limit BEFORE INSERT ON core.agents
FOR EACH ROW EXECUTE FUNCTION bill.assert_tenant_limit('agents');
DROP TRIGGER IF EXISTS trg_memberships_limit ON core.memberships;
CREATE TRIGGER trg_memberships_limit BEFORE INSERT ON core.memberships
FOR EACH ROW EXECUTE FUNCTION bill.assert_tenant_limit('users');
-- ---------------------------------------------------------------------
-- 4. Стан ліцензії інсталяції
-- ---------------------------------------------------------------------
-- ЧОМУ РІВЕНЬ ІНСТАЛЯЦІЇ, А НЕ КАБІНЕТУ
--
-- Бо ключ ставлять у продукт, який клієнт розгорнув У СЕБЕ. Кабінет там
-- один, і питання «яка ліцензія в кабінету Б» не виникає. Той самий
-- висновок, що й у 0064 про строки зберігання, і те саме обмеження, яке
-- треба знати заздалегідь: на спільному хостингу кількох клієнтів
-- ліцензія інсталяції накриє їх усіх.
--
-- Тому застосування ключа переписує entitlements лише тим кабінетам, у
-- яких source = 'license_key'. Кабінет, стелі якого прийшли з підписки
-- (source = 'stripe'), ключ інсталяції не чіпає — це і є та межа, за
-- якою платіжка не протікає в ліцензії, а ліцензії в платіжку.
CREATE TABLE IF NOT EXISTS bill.instance (
id boolean PRIMARY KEY DEFAULT true CHECK (id),
-- Ідентифікатор ЦІЄЇ інсталяції. Заводиться один раз і не міняється:
-- ключ, виданий на install_id, більше нікуди не підійде, і саме це
-- відрізняє ліцензію від пароля, який перешлють колезі.
--
-- Живе в базі, а не у файлі поруч із бінарником: контейнер
-- перезбирають, том із базою — ні.
install_id uuid NOT NULL DEFAULT core.new_id(),
-- Ключ як його ввела людина — цілком, разом із підписом.
--
-- Зберігається саме текстом, а не розібраним: перевірити підпис можна
-- лише над тими самими байтами, які підписували. Реконструкція
-- payload з колонок дала б інший канонічний вигляд і, отже, іншу
-- контрольну суму — тобто власна ліцензія перестала б проходити
-- перевірку після першої ж зміни схеми.
license_key text,
-- Розібраний і ПЕРЕВІРЕНИЙ payload. Дублює license_key навмисно:
-- запити на кшталт «чиї стелі зараз діють» не мають розбирати base64.
payload jsonb,
license_id uuid,
issued_to text,
-- unlicensed — ключа немає зовсім. Це робочий стан, а не поломка:
-- так виглядає щойно розгорнута інсталяція до покупки.
-- active — ключ дійсний.
-- grace — строк минув, пільговий період триває.
-- expired — минув і пільговий.
-- invalid — ключ є, але підпис/прив'язка не сходяться.
state text NOT NULL DEFAULT 'unlicensed'
CHECK (state IN ('unlicensed','active','grace','expired','invalid')),
-- Чому саме invalid — ФРАЗОЮ, а не кодом причини.
--
-- Сюди лягає текст помилки перевірки як є («невідомий ключ підпису
-- k2», «підпис ліцензії не сходиться»), і показується він людині
-- дослівно. Код причини довелося б перекладати назад у фразу ще в
-- одному місці, а перелік причин тут не є чимось, за чим фільтрують.
-- «Ключ недійсний» без причини перетворює звернення в підтримку на
-- вгадування — це і є те, чого колонка не допускає.
invalid_reason text,
expires_at timestamptz,
grace_until timestamptz,
-- МОНОТОННИЙ ГОДИННИК
--
-- Ліцензія без інтернету перевіряється за системним часом машини, а
-- машина належить тому, кого ліцензія обмежує. Перевести годинник на
-- рік назад — дія на одну команду.
--
-- Ловиться це найдешевшим способом, який взагалі є: пам'ятати
-- найпізніший час, який ця інсталяція БАЧИЛА. Час назад не йде; якщо
-- now() виявився суттєво меншим за побачене, годинник рухали.
--
-- Наслідок навмисно м'який: строк рахується за clock_max_seen, а не
-- за now(), і факт зсуву показується на сторінці. Вимикати щось за
-- це не можна — годинник з'їжджає й сам (сів CMOS, зник NTP після
-- переїзду в ізольований сегмент), і покарати за це означало б
-- покарати за несправність, а не за обхід.
clock_max_seen timestamptz NOT NULL DEFAULT now(),
clock_warped_at timestamptz,
-- Коли востаннє перераховували стан. Порожнє поле при непорожньому
-- ключі означає, що такт перевірки не працює, — і це видно на
-- сторінці, а не лише в логах.
checked_at timestamptz,
applied_at timestamptz,
applied_by uuid REFERENCES core.users(id) ON DELETE SET NULL
);
INSERT INTO bill.instance (id) VALUES (true) ON CONFLICT DO NOTHING;
COMMENT ON TABLE bill.instance IS
'Ліцензія цієї інсталяції: install_id, ключ, стан і монотонний годинник';
COMMENT ON COLUMN bill.instance.clock_max_seen IS
'Найпізніший побачений час. Строк рахується за ним, а не за now(): годинник належить клієнту';
-- RLS тут немає, і це не пропуск: політика 0011 накладається на таблиці
-- з колонкою tenant_id, а в цієї її немає за побудовою — рівно як у
-- core.storage_config з 0064. Читання відкрите: ховати «ліцензія діє до
-- 1 березня» немає від кого, а НЕ бачити цього означає дізнатись про
-- прострочення від колеги. Право на зміну перевіряє застосунок
-- (billing:manage).
GRANT SELECT, INSERT, UPDATE ON bill.instance TO netpulse_app, netpulse_worker;
-- ---------------------------------------------------------------------
-- 5. Ключі, не прив'язані до кабінету
-- ---------------------------------------------------------------------
-- bill.license_keys.tenant_id оголошена в 0009 як NULLABLE — і це
-- правильно: ключ для self-hosted випускають ДО того, як у клієнта
-- з'явиться кабінет, а часто й на іншій інсталяції (у нас, а не в
-- нього). Але 0011 автоматом повісила на таблицю tenant_isolation з
-- USING (tenant_id = core.current_tenant()), а NULL = будь-що дає NULL,
-- тобто не TRUE. Наслідок: рядок із tenant_id IS NULL не видно НІКОМУ й
-- ніколи, включно з тим, хто його щойно вставив.
--
-- Тобто головний сценарій продукту («клієнт ставить систему в себе»)
-- був закритий політикою, написаною для іншого випадку. Помітити це
-- читанням 0009 неможливо — політики там немає, вона з'являється через
-- дві міграції й циклом по всіх таблицях одразу.
DROP POLICY IF EXISTS tenant_isolation ON bill.license_keys;
CREATE POLICY license_keys_visible ON bill.license_keys
USING (tenant_id IS NULL OR tenant_id = core.current_tenant())
WITH CHECK (tenant_id IS NULL OR tenant_id = core.current_tenant());
-- Ключ підписують Ed25519, а не RSA-4096 PSS, як планувала 0009.
--
-- Жодного ключа ще не видано (таблиця порожня на всіх інсталяціях),
-- тому ламати сумісність нема з чим, а різниця істотна саме для
-- ліцензії, яку ЛЮДИНА ВВОДИТЬ РУКАМИ: підпис RSA-4096 — це 512 байтів,
-- тобто близько 700 символів base64 на самий лише підпис. Ключ, який не
-- вміщається в поле й не переживає копіювання з листа, повертається до
-- нас зверненням у підтримку. Ed25519 дає 64 байти.
--
-- Друга причина важливіша за довжину. У RSA-PSS є що налаштувати
-- неправильно — хеш, MGF, довжина солі; у Ed25519 налаштувань немає
-- взагалі, і перевірка або сходиться, або ні. Для механізму, який
-- працює без інтернету й без можливості відкликати ключ на льоту, це
-- вирішальна властивість.
--
-- Колонки 0009 (signature bytea, signing_key_id text, payload jsonb)
-- підходять без змін — вони не називають алгоритму.
COMMENT ON COLUMN bill.license_keys.signature IS
'Ed25519 над канонічним payload (не RSA-PSS, як планувала 0009 — див. коментар 0069)';
COMMENT ON COLUMN bill.license_keys.signing_key_id IS
'Ідентифікатор ключа підпису для ротації; входить у сам ключ, щоб перевірка знала, чим перевіряти';
-- ---------------------------------------------------------------------
-- 6. Права
-- ---------------------------------------------------------------------
-- Обидва права заведено ще 0010 і обидва досі значились у
-- store/roles.go серед dormantPerms: ключ у базі є, коду, який його
-- питає, немає. Тепер він є, і рядки звідти прибираються тією ж
-- правкою — інакше екран ролей і далі попереджав би про право, яке вже
-- працює.
--
-- Розподіл між ними такий самий, як у сховища й дзеркала: ДИВИТИСЬ
-- вільно широко, МІНЯТИ — вузько. Але межа проходить не там, де
-- зазвичай.
--
-- billing:read — стан підписки, стелі й скільки зайнято. Це право
-- інженера, а не бухгалтера: «чому не заводиться шістнадцятий хост»
-- — питання того, хто заводить хости, і відповідь на нього має
-- бути в нього перед очима ДО того, як він упреться. Саме тому
-- сторінка показує стелю разом із використаним, а не лише рахунок.
--
-- billing:manage — застосувати ліцензійний ключ, змінити тариф.
-- Лише власник: 0010 навмисно не дала цього права навіть адміну.
UPDATE core.permissions
SET description = 'Перегляд тарифу, стель, використаного та стану ліцензії'
WHERE key = 'billing:read';
UPDATE core.permissions
SET description = 'Зміна тарифу, застосування ліцензійного ключа, платіжні дані'
WHERE key = 'billing:manage';
-- ---------------------------------------------------------------------
-- 7. Що лишається поза цією міграцією й чому
-- ---------------------------------------------------------------------
-- ЖОДНОГО «ВИМКНУТИ ВСЕ» ПІСЛЯ ПРОСТРОЧЕННЯ.
--
-- У схемі немає ні прапорця «заблоковано», ні тригера, який зупиняв би
-- збір. Це не забуто — це головне рішення задачі, і схема мусить його
-- витримувати, бо схема переживе будь-який застосунок.
--
-- Моніторинг, який перестав моніторити через несплачений рахунок, — це
-- аварія, яку спричинили ми, у мережі, за яку відповідає клієнт. Він не
-- побачить падіння магістралі й дізнається про нього від абонентів; ми
-- при цьому не отримаємо грошей, а отримаємо звернення й репутацію
-- продукту, який тихо перестав працювати. Жодна ліцензійна угода такої
-- відповідальності не покриває.
--
-- Тому після прострочення замерзає лише РІСТ: не з'являється новий
-- хост, зонд, користувач, мапа. Усе, що вже під наглядом, лишається під
-- наглядом безстроково — метрики збираються, алерти підіймаються,
-- сповіщення йдуть. Стеля при цьому ніколи не опускається нижче
-- фактично зайнятого: 500 хостів на протермінованій ліцензії лишаються
-- п'ятьмастами, а не падають до 15. Прострочена ліцензія перетворює
-- продукт із того, що росте, на те, що працює, — і це найсильніший
-- аргумент заплатити з усіх, які в нас є, бо клієнт продовжує бачити
-- цінність, а не її відсутність.
--
-- Реалізує це застосунок (store/billing_license.go): стелі
-- перераховуються в bill.entitlements як max(стеля_плану,
-- фактичноайнято). Тригери нижче про ліцензію не знають нічого — вони
-- бачать лише число в entitlements, і це навмисно: правило «не
-- опускати нижче зайнятого» має жити в одному місці, а не в кожному
-- тригері окремо.
-- Stripe: див. шапку. Таблиці 0009 лишаються порожніми.
-- bill.usage_daily не заповнюється й цією міграцією. Щоденний зріз
-- потрібен для per-device тарифікації (скільки виставити за місяць), а
-- виставляти рахунки поки нема чим. Заводити такт, який щоночі пише
-- рядок, який ніхто не читає, означало б зробити ще одну таблицю, що
-- росте без причини, — рівно те, проти чого написана 0064.