П'ять паралельних задач. Найцінніше в них — не можливості, а знайдене.
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 працює справжній.
641 lines
42 KiB
PL/PgSQL
641 lines
42 KiB
PL/PgSQL
-- =====================================================================
|
||
-- 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.
|