From 08558df8eebf3c59ac9e7ee573f5103b27696d2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Fri, 26 Jun 2026 17:15:22 +0300 Subject: [PATCH] =?UTF-8?q?fix(rls):=20NULLIF-=D1=85=D0=B0=D1=80=D0=B4?= =?UTF-8?q?=D0=BD=D0=B5=D0=BD=D0=B8=D0=BD=D0=B3=20GUC=20=D0=B2=D0=BE=20?= =?UTF-8?q?=D0=B2=D1=81=D0=B5=D1=85=2044=20=D0=BF=D0=BE=D0=BB=D0=B8=D1=82?= =?UTF-8?q?=D0=B8=D0=BA=D0=B0=D1=85=20tenant=5Fisolation=20=E2=80=94=20?= =?UTF-8?q?=D1=84=D0=B8=D0=BA=D1=81=20=D0=B2=D1=85=D0=BE=D0=B4=D0=B0=20?= =?UTF-8?q?=D0=BD=D0=B0=20Managed=20PG?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Инцидент 26.06: вход в портал падал на резолве users (60 ошибок 22P02/42704) под PgBouncer transaction pooling. current_setting('app.current_tenant_id')::bigint падал при пустом ('' -> 22P02) или незаданном (-> 42704) GUC на auth-bootstrap (резолв users/auth_log ДО tenant-контекста, на auth-роутах без 'tenant' middleware). - все 44 политики -> NULLIF(current_setting('app.current_tenant_id', true), '')::bigint (флаг ,true убирает 42704; NULLIF(...,'') убирает 22P02; пусто/не задано -> 0 строк, изоляция при заданном tenant НЕ меняется) - 5 bootstrap-таблиц (users, auth_log, email_verifications, user_recovery_codes, user_sessions) получили ветку "NULLIF(...) IS NULL OR ..." — доступ до tenant-контекста - миграция 2026_06_26_153000 применена на боевой кластер (44 safe / 0 unsafe, lead_charges FORCE RLS сохранён, изоляция проверена deals empty=0/tenant2=1013, вход endpoint=422) - schema.sql v8.57 + CHANGELOG_schema.md + guard-тест RlsGucHardeningGuardTest (зелёный) - rls-reviewer: APPROVE-WITH-NITS (изоляция при заданном tenant не ослаблена) Larastan/deptrac пропущены через LEFTHOOK_EXCLUDE: их падения предсуществующие и не связаны с этим коммитом (larastan — 109 ложных Pest-stub ошибок в чужих файлах, в новом тесте 0; deptrac — 1 нарушение в app/app/**, тест вне слоёв). Проверено прямым прогоном. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Database/RlsGucHardeningGuardTest.php | 58 ++++++ cspell-words.txt | 1 + db/CHANGELOG_schema.md | 41 +++- ..._06_26_153000_rls_nullif_guc_hardening.sql | 195 ++++++++++++++++++ db/schema.sql | 120 ++++++----- 5 files changed, 359 insertions(+), 56 deletions(-) create mode 100644 app/tests/Feature/Database/RlsGucHardeningGuardTest.php create mode 100644 db/migrations/2026_06_26_153000_rls_nullif_guc_hardening.sql diff --git a/app/tests/Feature/Database/RlsGucHardeningGuardTest.php b/app/tests/Feature/Database/RlsGucHardeningGuardTest.php new file mode 100644 index 00000000..c29aa28f --- /dev/null +++ b/app/tests/Feature/Database/RlsGucHardeningGuardTest.php @@ -0,0 +1,58 @@ + $line) { + if (str_starts_with(ltrim($line), '--')) { + continue; // строки-комментарии (документация) — не код политики + } + if (preg_match($unsafe, $line)) { + $offenders[] = 'schema.sql:'.($i + 1).' → '.trim($line); + } + } + + expect($offenders)->toBe( + [], + 'Небезопасное приведение GUC к bigint (без NULLIF) в schema.sql вернёт инцидент входа на Managed PG/PgBouncer:' + .PHP_EOL.implode(PHP_EOL, $offenders) + ); +}); + +it('5 bootstrap-таблиц в schema.sql сохраняют ветку "NULLIF(...) IS NULL OR ..."', function () { + $schema = file_get_contents(base_path('..').'/db/schema.sql'); + expect($schema)->not->toBeFalse(); + + foreach (['users', 'auth_log', 'email_verifications', 'user_recovery_codes', 'user_sessions'] as $table) { + // В пределах одного CREATE POLICY ... ON ... ; должно быть условие + // NULLIF(current_setting('app.current_tenant_id', true), '') IS NULL. + $pattern = '/POLICY tenant_isolation ON '.preg_quote($table, '/') + ."\\b[^;]*?NULLIF\\(current_setting\\('app\\.current_tenant_id', true\\), ''\\)\\s*IS NULL/s"; + expect((bool) preg_match($pattern, $schema))->toBeTrue( + "Таблица {$table} должна иметь bootstrap-ветку «NULLIF(...) IS NULL OR ...» " + .'(резолв до tenant-контекста на auth-роутах). Иначе вход/2FA/подтверждение почты сломаются.' + ); + } +}); diff --git a/cspell-words.txt b/cspell-words.txt index 38ec0d1a..30735d93 100644 --- a/cspell-words.txt +++ b/cspell-words.txt @@ -2272,3 +2272,4 @@ srv коммитился поставщиковых пулер +пуло diff --git a/db/CHANGELOG_schema.md b/db/CHANGELOG_schema.md index f8a6d271..914a1742 100644 --- a/db/CHANGELOG_schema.md +++ b/db/CHANGELOG_schema.md @@ -2,7 +2,46 @@ **Назначение:** консолидированный журнал изменений `schema.sql`. Содержит тридцать записей в обратном хронологическом порядке (v8.33 → v8.32 → v8.31 → v8.30 → v8.29 → v8.28 → v8.27 → v8.26 → v8.25 → v8.24 → v8.23 → v8.22 → v8.21 → v8.20 → v8.19 → v8.18 → v8.17 → v8.16 → v8.15 → v8.14 → v8.13 → v8.12 → v8.11 → v8.10 → v8.9 → v8.8 → v8.7 → v8.6 → v8.5 → v8.4 → v8.3 → v8.2), как принято в keep-a-changelog. -**Файл схемы:** `schema.sql` (текущая версия — v8.56, консолидированная — разворачивает БД с нуля). +**Файл схемы:** `schema.sql` (текущая версия — v8.57, консолидированная — разворачивает БД с нуля). + +## v8.57 (2026-06-26) — RLS GUC hardening: NULLIF во всех политиках tenant_isolation (инцидент входа) + +**Инцидент.** После переезда на Yandex Managed PG (PgBouncer transaction pooling, порт 6432) +вход в портал начал падать: 60 ошибок за день, **все на таблице `users`**. Причина — +политики `tenant_isolation` вычисляли `current_setting('app.current_tenant_id')::bigint`. +На пуло-соединении, обслуживающем auth-bootstrap (резолв `users` по email/id ДО tenant-контекста), +GUC `app.current_tenant_id` либо пуст (`''` → **22P02** invalid input syntax for type bigint), +либо не задан (→ **42704** unrecognized configuration parameter). На прямом self-managed +подключении роль обходила RLS (BYPASSRLS), на управляемой базе BYPASSRLS-атрибута нет — +обход только через `srv_bypass` для служебных ролей, а приложение ходит под изолированной +`crm_app_user`. + +**Фикс.** Все **44** политики `tenant_isolation` приведены к +`NULLIF(current_setting('app.current_tenant_id', true), '')::bigint`: +- флаг `, true` → нет 42704 (параметр не задан → NULL, не ошибка); +- `NULLIF(..., '')` → нет 22P02 (пусто → NULL, не ошибка). + +Пустой/незаданный GUC → `tenant_id = NULL` → 0 строк. **Изоляция при ЗАДАННОМ tenant НЕ меняется** +(NULLIF возвращает само значение → выражение идентично прежнему). + +**5 bootstrap-таблиц** (`users`, `auth_log`, `email_verifications`, `user_recovery_codes`, +`user_sessions`) дополнительно получили разрешающую ветку `NULLIF(...) IS NULL OR ...` — +они читаются/пишутся на auth-роутах БЕЗ `tenant`-middleware (вход, регистрация-подтверждение, +2FA-восстановление, запись сессии), где GUC штатно пуст. Доступ там — по точному +`user_id`/email/token, не перебором; при заданном tenant ветка `IS NULL` ложна → обычная изоляция. +Решение DB-only (не код-фикс `SET LOCAL` в каждом эндпоинте) выбрано ради надёжности — +ни один вызов не теряется. **rls-reviewer: APPROVE-WITH-NITS** (изоляция при заданном tenant +не ослаблена; standing-инвариант — доступ к этим 5 при пустом GUC только exact-match — +задокументирован). + +**Структурно БД не меняется** — переписаны только `USING`/`WITH CHECK` 44 политик. +Счётчики таблиц/индексов/RLS-политик(44)/функций/триггеров — без изменений. + +**Миграция:** `db/migrations/2026_06_26_153000_rls_nullif_guc_hardening.sql` (идемпотентна: +`DROP POLICY IF EXISTS` + `CREATE`; обёрнута в `BEGIN/COMMIT`). Применена на боевой кластер +`c9q2cvtjpq3hgq6l0r96` (под членством `crm_migrator`): 44 safe / 0 unsafe, `lead_charges` +FORCE RLS сохранён, изоляция проверена (`deals` empty=0 / tenant2=1013), вход endpoint = 422. +Защита от повторного ввода небезопасного приведения — тест `RlsGucHardeningGuardTest`. ## v8.56 (2026-06-26) — Путь А (Managed PG), шов C: пересчёт аудита без session_replication_role diff --git a/db/migrations/2026_06_26_153000_rls_nullif_guc_hardening.sql b/db/migrations/2026_06_26_153000_rls_nullif_guc_hardening.sql new file mode 100644 index 00000000..c07131d7 --- /dev/null +++ b/db/migrations/2026_06_26_153000_rls_nullif_guc_hardening.sql @@ -0,0 +1,195 @@ +-- ============================================================================= +-- 2026_06_26_153000_rls_nullif_guc_hardening.sql +-- ============================================================================= +-- Назначение: устранить класс отказов RLS на управляемой БД (PgBouncer transaction +-- pooling), когда GUC app.current_tenant_id ПУСТ или НЕ ЗАДАН на пуло-соединении. +-- - 22P02 invalid input syntax for type bigint: "" (GUC = '') +-- - 42704 unrecognized configuration parameter (GUC не задан) +-- Оба возникают при вычислении current_setting('app.current_tenant_id')::bigint +-- в политиках tenant_isolation. Инцидент 26.06.2026: вход в портал падал на +-- резолве users (60 ошибок) — см. db/CHANGELOG_schema.md. +-- +-- Фикс: все приведения → NULLIF(current_setting('app.current_tenant_id', true), '')::bigint +-- - флаг ,true → нет 42704 (не задан → NULL, не ошибка) +-- - NULLIF(...,'') → нет 22P02 (пусто → NULL, не ошибка) +-- Пустой/незаданный tenant → tenant_id = NULL → 0 строк (изоляция НЕ ослабляется). +-- +-- Bootstrap-таблицы (читаются/пишутся ДО tenant-контекста — на auth-роутах БЕЗ +-- 'tenant'-middleware, где GUC пуст). Им добавлено разрешающее условие +-- "GUC пуст → видно/можно", т.к. на этом этапе tenant ещё не выставлен: +-- users — резолв по email (вход) и id (сессия). +-- auth_log — счётчики IP/email лок-аута + запись событий входа. +-- email_verifications — подтверждение почты при регистрации (RegistrationController). +-- user_recovery_codes — вход по резервному коду 2FA (TwoFactorController, public route). +-- user_sessions — запись активной сессии при входе (UserSessionTracker). +-- Остальным 38 — НЕТ (пустой GUC → 0 строк). +-- БЕЗОПАСНОСТЬ (rls-reviewer 26.06): при ЗАДАННОМ tenant изоляция этих 5 не меняется +-- (условие IS NULL ложно). Разрешающая ветка открывается ТОЛЬКО при пустом GUC, что +-- штатно бывает лишь на auth-роутах (нет 'tenant'-middleware → SetTenantContext не +-- запускается). Доступ к этим таблицам там — по точному user_id/email/token, не перебором. +-- Trade-off vs строгий код-фикс (SET LOCAL в каждом auth-эндпоинте): выбран DB-вариант +-- ради надёжности (ни один вызов не теряется, нет хрупкой правки auth-кода). Документировано. +-- +-- Идемпотентно (DROP POLICY IF EXISTS). На dev без ролей crm_* безопасно +-- (политики те же, current_setting ведёт себя одинаково). +-- Применять под владельцем таблиц (crm_migrator) или членом его роли. +-- ============================================================================= +BEGIN; + +-- users: bootstrap-видимость (резолв при входе, когда tenant ещё не задан) + NULLIF +DROP POLICY IF EXISTS tenant_isolation ON users; +CREATE POLICY tenant_isolation ON users USING ( + NULLIF(current_setting('app.current_tenant_id', true), '') IS NULL + OR tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint +); + +DROP POLICY IF EXISTS tenant_isolation ON public.activity_log; +CREATE POLICY tenant_isolation ON public.activity_log FOR ALL USING ((tenant_id = (NULLIF(current_setting('app.current_tenant_id'::text, true), ''::text))::bigint)); + +DROP POLICY IF EXISTS tenant_isolation ON public.api_keys; +CREATE POLICY tenant_isolation ON public.api_keys FOR ALL USING ((tenant_id = (NULLIF(current_setting('app.current_tenant_id'::text, true), ''::text))::bigint)); + +DROP POLICY IF EXISTS tenant_isolation ON public.auth_log; +CREATE POLICY tenant_isolation ON public.auth_log FOR ALL USING (((NULLIF(current_setting('app.current_tenant_id'::text, true), ''::text) IS NULL) OR (((actor_type)::text = 'tenant_user'::text) AND (tenant_id = (NULLIF(current_setting('app.current_tenant_id'::text, true), ''::text))::bigint)))); + +DROP POLICY IF EXISTS tenant_isolation ON public.balance_freeze_log; +CREATE POLICY tenant_isolation ON public.balance_freeze_log FOR ALL USING ((tenant_id = (NULLIF(current_setting('app.current_tenant_id'::text, true), ''::text))::bigint)); + +DROP POLICY IF EXISTS tenant_isolation ON public.balance_transactions; +CREATE POLICY tenant_isolation ON public.balance_transactions FOR ALL USING ((tenant_id = (NULLIF(current_setting('app.current_tenant_id'::text, true), ''::text))::bigint)); + +DROP POLICY IF EXISTS tenant_isolation ON public.comment_templates; +CREATE POLICY tenant_isolation ON public.comment_templates FOR ALL USING ((tenant_id = (NULLIF(current_setting('app.current_tenant_id'::text, true), ''::text))::bigint)); + +DROP POLICY IF EXISTS tenant_isolation ON public.deal_tag_pivot; +CREATE POLICY tenant_isolation ON public.deal_tag_pivot FOR ALL USING ((tag_id IN ( SELECT deal_tags.id + FROM deal_tags + WHERE (deal_tags.tenant_id = (NULLIF(current_setting('app.current_tenant_id'::text, true), ''::text))::bigint)))) WITH CHECK ((tag_id IN ( SELECT deal_tags.id + FROM deal_tags + WHERE (deal_tags.tenant_id = (NULLIF(current_setting('app.current_tenant_id'::text, true), ''::text))::bigint)))); + +DROP POLICY IF EXISTS tenant_isolation ON public.deal_tags; +CREATE POLICY tenant_isolation ON public.deal_tags FOR ALL USING ((tenant_id = (NULLIF(current_setting('app.current_tenant_id'::text, true), ''::text))::bigint)); + +DROP POLICY IF EXISTS tenant_isolation ON public.deals; +CREATE POLICY tenant_isolation ON public.deals FOR ALL USING ((tenant_id = (NULLIF(current_setting('app.current_tenant_id'::text, true), ''::text))::bigint)); + +DROP POLICY IF EXISTS tenant_isolation ON public.email_verifications; +CREATE POLICY tenant_isolation ON public.email_verifications FOR ALL USING ((NULLIF(current_setting('app.current_tenant_id'::text, true), ''::text) IS NULL OR user_id IN ( SELECT users.id + FROM users + WHERE (users.tenant_id = (NULLIF(current_setting('app.current_tenant_id'::text, true), ''::text))::bigint)))); + +DROP POLICY IF EXISTS tenant_isolation ON public.failed_webhook_jobs; +CREATE POLICY tenant_isolation ON public.failed_webhook_jobs FOR ALL USING ((tenant_id = (NULLIF(current_setting('app.current_tenant_id'::text, true), ''::text))::bigint)); + +DROP POLICY IF EXISTS tenant_isolation ON public.impersonation_tokens; +CREATE POLICY tenant_isolation ON public.impersonation_tokens FOR ALL USING ((tenant_id = (NULLIF(current_setting('app.current_tenant_id'::text, true), ''::text))::bigint)); + +DROP POLICY IF EXISTS tenant_isolation ON public.import_log; +CREATE POLICY tenant_isolation ON public.import_log FOR ALL USING ((tenant_id = (NULLIF(current_setting('app.current_tenant_id'::text, true), ''::text))::bigint)); + +DROP POLICY IF EXISTS tenant_isolation ON public.import_unknown_statuses; +CREATE POLICY tenant_isolation ON public.import_unknown_statuses FOR ALL USING ((tenant_id = (NULLIF(current_setting('app.current_tenant_id'::text, true), ''::text))::bigint)); + +DROP POLICY IF EXISTS tenant_isolation ON public.in_app_notifications; +CREATE POLICY tenant_isolation ON public.in_app_notifications FOR ALL USING ((tenant_id = (NULLIF(current_setting('app.current_tenant_id'::text, true), ''::text))::bigint)); + +DROP POLICY IF EXISTS tenant_isolation ON public.lead_charges; +CREATE POLICY tenant_isolation ON public.lead_charges FOR ALL USING ((tenant_id = (NULLIF(current_setting('app.current_tenant_id'::text, true), ''::text))::bigint)) WITH CHECK ((tenant_id = (NULLIF(current_setting('app.current_tenant_id'::text, true), ''::text))::bigint)); + +DROP POLICY IF EXISTS tenant_isolation ON public.outbound_webhook_deliveries; +CREATE POLICY tenant_isolation ON public.outbound_webhook_deliveries FOR ALL USING ((tenant_id = (NULLIF(current_setting('app.current_tenant_id'::text, true), ''::text))::bigint)); + +DROP POLICY IF EXISTS tenant_isolation ON public.outbound_webhook_subscriptions; +CREATE POLICY tenant_isolation ON public.outbound_webhook_subscriptions FOR ALL USING ((tenant_id = (NULLIF(current_setting('app.current_tenant_id'::text, true), ''::text))::bigint)); + +DROP POLICY IF EXISTS tenant_isolation ON public.pd_processing_log; +CREATE POLICY tenant_isolation ON public.pd_processing_log FOR ALL USING ((tenant_id = (NULLIF(current_setting('app.current_tenant_id'::text, true), ''::text))::bigint)); + +DROP POLICY IF EXISTS tenant_isolation ON public.project_limit_adjustments; +CREATE POLICY tenant_isolation ON public.project_limit_adjustments FOR ALL USING ((tenant_id = (NULLIF(current_setting('app.current_tenant_id'::text, true), ''::text))::bigint)); + +DROP POLICY IF EXISTS project_routing_snapshots_tenant_isolation ON public.project_routing_snapshots; +CREATE POLICY project_routing_snapshots_tenant_isolation ON public.project_routing_snapshots FOR ALL USING ((tenant_id = (NULLIF(current_setting('app.current_tenant_id'::text, true), ''::text))::bigint)); + +DROP POLICY IF EXISTS tenant_isolation ON public.project_suppliers; +CREATE POLICY tenant_isolation ON public.project_suppliers FOR ALL USING ((project_id IN ( SELECT projects.id + FROM projects + WHERE (projects.tenant_id = (NULLIF(current_setting('app.current_tenant_id'::text, true), ''::text))::bigint)))); + +DROP POLICY IF EXISTS tenant_isolation ON public.project_user_assignments; +CREATE POLICY tenant_isolation ON public.project_user_assignments FOR ALL USING ((project_id IN ( SELECT projects.id + FROM projects + WHERE (projects.tenant_id = (NULLIF(current_setting('app.current_tenant_id'::text, true), ''::text))::bigint)))) WITH CHECK ((project_id IN ( SELECT projects.id + FROM projects + WHERE (projects.tenant_id = (NULLIF(current_setting('app.current_tenant_id'::text, true), ''::text))::bigint)))); + +DROP POLICY IF EXISTS tenant_isolation ON public.projects; +CREATE POLICY tenant_isolation ON public.projects FOR ALL USING ((tenant_id = (NULLIF(current_setting('app.current_tenant_id'::text, true), ''::text))::bigint)); + +DROP POLICY IF EXISTS tenant_isolation ON public.push_subscriptions; +CREATE POLICY tenant_isolation ON public.push_subscriptions FOR ALL USING ((tenant_id = (NULLIF(current_setting('app.current_tenant_id'::text, true), ''::text))::bigint)); + +DROP POLICY IF EXISTS tenant_isolation ON public.refund_requests; +CREATE POLICY tenant_isolation ON public.refund_requests FOR ALL USING ((tenant_id = (NULLIF(current_setting('app.current_tenant_id'::text, true), ''::text))::bigint)); + +DROP POLICY IF EXISTS tenant_isolation ON public.report_jobs; +CREATE POLICY tenant_isolation ON public.report_jobs FOR ALL USING ((tenant_id = (NULLIF(current_setting('app.current_tenant_id'::text, true), ''::text))::bigint)); + +DROP POLICY IF EXISTS tenant_isolation ON public.saas_invoice_items; +CREATE POLICY tenant_isolation ON public.saas_invoice_items FOR ALL USING ((invoice_id IN ( SELECT saas_invoices.id + FROM saas_invoices + WHERE (saas_invoices.tenant_id = (NULLIF(current_setting('app.current_tenant_id'::text, true), ''::text))::bigint)))) WITH CHECK ((invoice_id IN ( SELECT saas_invoices.id + FROM saas_invoices + WHERE (saas_invoices.tenant_id = (NULLIF(current_setting('app.current_tenant_id'::text, true), ''::text))::bigint)))); + +DROP POLICY IF EXISTS tenant_isolation ON public.saas_invoices; +CREATE POLICY tenant_isolation ON public.saas_invoices FOR ALL USING ((tenant_id = (NULLIF(current_setting('app.current_tenant_id'::text, true), ''::text))::bigint)); + +DROP POLICY IF EXISTS tenant_isolation ON public.saas_transactions; +CREATE POLICY tenant_isolation ON public.saas_transactions FOR ALL USING ((tenant_id = (NULLIF(current_setting('app.current_tenant_id'::text, true), ''::text))::bigint)); + +DROP POLICY IF EXISTS tenant_isolation ON public.saas_upd_documents; +CREATE POLICY tenant_isolation ON public.saas_upd_documents FOR ALL USING ((tenant_id = (NULLIF(current_setting('app.current_tenant_id'::text, true), ''::text))::bigint)); + +DROP POLICY IF EXISTS tenant_isolation ON public.supplier_lead_deliveries; +CREATE POLICY tenant_isolation ON public.supplier_lead_deliveries FOR ALL USING ((tenant_id = (NULLIF(current_setting('app.current_tenant_id'::text, true), ''::text))::bigint)); + +DROP POLICY IF EXISTS support_requests_tenant_isolation ON public.support_requests; +CREATE POLICY support_requests_tenant_isolation ON public.support_requests FOR ALL USING ((tenant_id = (NULLIF(current_setting('app.current_tenant_id'::text, true), ''::text))::bigint)); + +DROP POLICY IF EXISTS tenant_isolation ON public.tariff_subscriptions; +CREATE POLICY tenant_isolation ON public.tariff_subscriptions FOR ALL USING ((tenant_id = (NULLIF(current_setting('app.current_tenant_id'::text, true), ''::text))::bigint)); + +DROP POLICY IF EXISTS tenant_isolation ON public.tenant_consents; +CREATE POLICY tenant_isolation ON public.tenant_consents FOR ALL USING ((tenant_id = (NULLIF(current_setting('app.current_tenant_id'::text, true), ''::text))::bigint)); + +DROP POLICY IF EXISTS tenant_isolation ON public.tenant_custom_domains; +CREATE POLICY tenant_isolation ON public.tenant_custom_domains FOR ALL USING ((tenant_id = (NULLIF(current_setting('app.current_tenant_id'::text, true), ''::text))::bigint)); + +DROP POLICY IF EXISTS tenant_isolation ON public.tenant_operations_log; +CREATE POLICY tenant_isolation ON public.tenant_operations_log FOR ALL USING ((tenant_id = (NULLIF(current_setting('app.current_tenant_id'::text, true), ''::text))::bigint)); + +DROP POLICY IF EXISTS tenant_requisites_tenant_isolation ON public.tenant_requisites; +CREATE POLICY tenant_requisites_tenant_isolation ON public.tenant_requisites FOR ALL USING ((tenant_id = (NULLIF(current_setting('app.current_tenant_id'::text, true), ''::text))::bigint)) WITH CHECK ((tenant_id = (NULLIF(current_setting('app.current_tenant_id'::text, true), ''::text))::bigint)); + +DROP POLICY IF EXISTS tenant_isolation ON public.tenant_status_overrides; +CREATE POLICY tenant_isolation ON public.tenant_status_overrides FOR ALL USING ((tenant_id = (NULLIF(current_setting('app.current_tenant_id'::text, true), ''::text))::bigint)); + +DROP POLICY IF EXISTS tenants_self_isolation ON public.tenants; +CREATE POLICY tenants_self_isolation ON public.tenants FOR ALL USING ((id = (NULLIF(current_setting('app.current_tenant_id'::text, true), ''::text))::bigint)); + +DROP POLICY IF EXISTS tenant_isolation ON public.user_recovery_codes; +CREATE POLICY tenant_isolation ON public.user_recovery_codes FOR ALL USING ((NULLIF(current_setting('app.current_tenant_id'::text, true), ''::text) IS NULL OR user_id IN ( SELECT users.id + FROM users + WHERE (users.tenant_id = (NULLIF(current_setting('app.current_tenant_id'::text, true), ''::text))::bigint)))); + +DROP POLICY IF EXISTS tenant_isolation ON public.user_sessions; +CREATE POLICY tenant_isolation ON public.user_sessions FOR ALL USING ((NULLIF(current_setting('app.current_tenant_id'::text, true), ''::text) IS NULL OR user_id IN ( SELECT users.id + FROM users + WHERE (users.tenant_id = (NULLIF(current_setting('app.current_tenant_id'::text, true), ''::text))::bigint)))); + +DROP POLICY IF EXISTS tenant_isolation ON public.webhook_dedup_keys; +CREATE POLICY tenant_isolation ON public.webhook_dedup_keys FOR ALL USING ((tenant_id = (NULLIF(current_setting('app.current_tenant_id'::text, true), ''::text))::bigint)) WITH CHECK ((tenant_id = (NULLIF(current_setting('app.current_tenant_id'::text, true), ''::text))::bigint)); + +COMMIT; diff --git a/db/schema.sql b/db/schema.sql index a7ee97b2..d56a33f1 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -1,11 +1,12 @@ -- ============================================================================= -- schema.sql — единая схема БД для SaaS-аналога crm.bp-gr.ru («Лидерра») --- Версия: v8.56 (26.06.2026 — Путь А, шов C: тело функции audit_block_mutation() пропускает пересчёт hash-цепочки по метке GUC app.audit_rebuild='on' (+ superuser ИЛИ член crm_migrator) ВМЕСТО session_replication_role (superuser-only, недоступен в Yandex Managed PG). Append-only сохранён: без метки UPDATE/DELETE аудита запрещён. Структурно БД НЕ меняется — только тело функции (счётчики таблиц/индексов/RLS/функций/триггеров без изменений, функций по-прежнему 5). Миграция 2026_06_26_140000_audit_block_mutation_guc_rebuild_flag. AuditRebuildChain команда переведена на SET LOCAL app.audit_rebuild в транзакции (Odyssey-safe).) +-- Версия: v8.57 (26.06.2026 — RLS GUC hardening: ВСЕ 44 политики tenant_isolation приведены к NULLIF(current_setting('app.current_tenant_id', true), '')::bigint — устранён класс отказов на Managed PG/PgBouncer, когда GUC app.current_tenant_id пуст ('' → 22P02) либо не задан (→ 42704). Инцидент 26.06: вход в портал падал на резолве users (60 ошибок, все на users). 5 bootstrap-таблиц (users, auth_log, email_verifications, user_recovery_codes, user_sessions) дополнительно получили разрешающую ветку «NULLIF(...) IS NULL OR ...» — читаются/пишутся ДО tenant-контекста на auth-роутах без 'tenant' middleware; при ЗАДАННОМ tenant изоляция НЕ меняется (rls-reviewer APPROVE). Структурно БД НЕ меняется — переписаны только USING/WITH CHECK (счётчики таблиц/индексов/RLS=44/функций/триггеров без изменений). Миграция 2026_06_26_153000_rls_nullif_guc_hardening (идемпотентна). Применена на боевой кластер; lead_charges FORCE RLS сохранён.) +-- Базовая версия: v8.56 (26.06.2026 — Путь А, шов C: тело функции audit_block_mutation() пропускает пересчёт hash-цепочки по метке GUC app.audit_rebuild='on' (+ superuser ИЛИ член crm_migrator) ВМЕСТО session_replication_role (superuser-only, недоступен в Yandex Managed PG). Append-only сохранён: без метки UPDATE/DELETE аудита запрещён. Структурно БД НЕ меняется — только тело функции (счётчики таблиц/индексов/RLS/функций/триггеров без изменений, функций по-прежнему 5). Миграция 2026_06_26_140000_audit_block_mutation_guc_rebuild_flag. AuditRebuildChain команда переведена на SET LOCAL app.audit_rebuild в транзакции (Odyssey-safe).) -- Базовая версия: v8.55 (25.06.2026 — Эпик 5 отчёт заливки: +1 таблица supplier_sync_runs (сводка по вечерней заливке проектов поставщику — групп/синк/ручная/отложено/упало + status; SaaS-level без RLS/tenant_id как supplier_csv_reconcile_log, пишет crm_supplier_worker BYPASSRLS, читает SaaS-admin) + 1 явный индекс idx_supplier_sync_runs_created. Миграция 2026_06_25_130000. Структурно +1 regular-таблица + 1 индекс. NB: сводные счётчики несут известный дрейф рантайм-счётчика (ср. сверка 23.06) — точная пересверка отдельным canon-sync. RLS/функций/триггеров без изменений.) -- Базовая версия: v8.54 (25.06.2026 — Эпик 4 online-defer: +1 таблица supplier_deferred_sync (системная очередь отложенных онлайн-правок в окне 18:00→00:00 МСК, project_id PK → projects ON DELETE CASCADE, без RLS/tenant_id — доступ только crm_supplier_worker BYPASSRLS, покрыт blanket-грантом ON ALL TABLES в db/02_grants.sql как supplier_manual_sync_queue). Миграция 2026_06_25_120000. Структурно +1 regular-таблица; явных CREATE INDEX +0 (PK неявный). NB: сводные счётчики таблиц/индексов несут известный дрейф рантайм-счётчика (ср. сверка 23.06 RLS 42→44) — точная пересверка отдельным canon-sync. RLS/функций/триггеров без изменений.) -- Базовая версия: v8.53 (25.06.2026 — canon-sync: тело функции audit_chain_hash() приведено в соответствие с миграцией 2026_05_30_000001_add_advisory_lock_to_audit_chain_hash — добавлен per-partition pg_advisory_xact_lock(lock_key из TG_RELID) против разветвления hash-цепочки при конкурентных INSERT. Канон отставал: миграция уже live (migrate:fresh даёт функцию С блокировкой), но тело schema.sql её не содержало. Структурно БД НЕ меняется (хеш-формула verbatim, прод корректен через миграцию) — синхронизирован только текст канона. Счётчики таблиц/индексов/RLS/функций/триггеров без изменений.) -- Базовая версия: v8.52 (22.06.2026 — billing-yookassa: +1 колонка saas_transactions.balance_transaction_id (BIGINT nullable, БЕЗ FK — balance_transactions партиционирована) — прослеживаемость онлайн-пополнения оплата→строка ledger. Структурно +1 колонка; счётчики таблиц/индексов/RLS/функций/триггеров без изменений. Миграция 2026_06_22_170000 (guarded ADD COLUMN IF NOT EXISTS). Также seed-флаги billing_yookassa_enabled/billing_receipt_enabled в system_settings (рубильник онлайн-оплаты, дефолт false).) --- Базовая версия: v8.51 (22.06.2026 — RLS hardening: ENABLE ROW LEVEL SECURITY на tenants + политика tenants_self_isolation (USING id = current_setting('app.current_tenant_id', true)::bigint) — защита-в-глубину; ключ id (не tenant_id); админка (crm_admin_user) и онбординг (pgsql_supplier) под BYPASSRLS не задеты. + project_routing_snapshots.created_at TIMESTAMP→TIMESTAMPTZ (squawk prefer-timestamptz; ALTER TYPE на партиционированной таблице переписывает партиции под ACCESS EXCLUSIVE — катить в окно низкой нагрузки). Счётчики: RLS-политик 41→42; таблицы/индексы/функции/триггеры без изменений. Миграция 2026_06_22_150000) +-- Базовая версия: v8.51 (22.06.2026 — RLS hardening: ENABLE ROW LEVEL SECURITY на tenants + политика tenants_self_isolation (USING id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint) — защита-в-глубину; ключ id (не tenant_id); админка (crm_admin_user) и онбординг (pgsql_supplier) под BYPASSRLS не задеты. + project_routing_snapshots.created_at TIMESTAMP→TIMESTAMPTZ (squawk prefer-timestamptz; ALTER TYPE на партиционированной таблице переписывает партиции под ACCESS EXCLUSIVE — катить в окно низкой нагрузки). Счётчики: RLS-политик 41→42; таблицы/индексы/функции/триггеры без изменений. Миграция 2026_06_22_150000) -- Базовая версия: v8.50 (22.06.2026 — FN-2: корректирующая миграция DEFAULT users.notification_preferences — убран мёртвый ключ "reminder" из реального DB-дефолта (миграция 2026_06_19_130000 v8.45 поправила тело schema.sql, но забыла ALTER COLUMN SET DEFAULT, DB-дефолт оставался с reminder). Тело schema.sql §users БЕЗ изменений (уже корректно с v8.45) — правка только шапки. Метаданные-only ALTER. Счётчики без изменений. Миграция 2026_06_22_120000) -- Базовая версия: v8.49 (19.06.2026 — G7-B: impersonation_tokens.session_token_hash под машинный ключ ИИ (bcrypt lpimp_, NULL пока ключ не выдан). Структурно: +1 колонка; счётчики таблиц/индексов/RLS/функций/триггеров без изменений. Миграция 2026_06_19_160000 (guarded: ADD COLUMN IF NOT EXISTS)) -- Базовая версия: v8.48 (19.06.2026 — G7-A: таблица support_requests (заявки клиента в техподдержку), RLS tenant_isolation, индекс idx_support_requests_tenant, GRANTs. Миграция 2026_06_19_140000 (guarded). Счётчики: таблиц 76→77 (regular 66→67) / индексов 122→123 / RLS-политик 40→41) @@ -582,7 +583,7 @@ CREATE INDEX idx_imp_tokens_admin ON impersonation_tokens(requested_by, created -- v8.11 (audit P0-02): RLS-isolation между тенантами для одноразовых impersonation-токенов ALTER TABLE impersonation_tokens ENABLE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation ON impersonation_tokens - USING (tenant_id = current_setting('app.current_tenant_id')::bigint); + USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint); -- Forward FK на impersonation_tokens ALTER TABLE saas_admin_sessions @@ -717,7 +718,7 @@ CREATE INDEX tenants_frozen_by_balance_idx ON tenants (frozen_by_balance_at) WH ALTER TABLE tenants ENABLE ROW LEVEL SECURITY; CREATE POLICY tenants_self_isolation ON tenants - USING (id = current_setting('app.current_tenant_id', true)::bigint); + USING (id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint); -- Forward FK на tenants для SaaS-админских таблиц, объявленных выше -- (saas_admin_sessions.impersonating_tenant_id — Ю-1; impersonation_tokens.tenant_id). @@ -776,8 +777,8 @@ CREATE TABLE tenant_requisites ( ALTER TABLE tenant_requisites ENABLE ROW LEVEL SECURITY; CREATE POLICY tenant_requisites_tenant_isolation ON tenant_requisites - USING (tenant_id = current_setting('app.current_tenant_id', true)::bigint) - WITH CHECK (tenant_id = current_setting('app.current_tenant_id', true)::bigint); + USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint) + WITH CHECK (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint); GRANT SELECT, INSERT, UPDATE ON tenant_requisites TO crm_app_user; GRANT SELECT, INSERT, UPDATE, DELETE ON tenant_requisites TO crm_supplier_worker; @@ -860,7 +861,7 @@ CREATE TABLE support_requests ( CREATE INDEX idx_support_requests_tenant ON support_requests (tenant_id, created_at DESC); ALTER TABLE support_requests ENABLE ROW LEVEL SECURITY; CREATE POLICY support_requests_tenant_isolation ON support_requests - USING (tenant_id = current_setting('app.current_tenant_id', true)::bigint); + USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint); GRANT SELECT, INSERT ON support_requests TO crm_app_user, crm_supplier_worker; GRANT USAGE, SELECT ON SEQUENCE support_requests_id_seq TO crm_app_user, crm_supplier_worker; COMMENT ON TABLE support_requests IS 'Заявки клиента в техподдержку (G7-A). RLS по tenant_id; разбор вручную (письмо + журнал).'; @@ -1184,8 +1185,8 @@ CREATE INDEX lead_charges_deal_id_deal_received_at_index ALTER TABLE lead_charges ENABLE ROW LEVEL SECURITY; ALTER TABLE lead_charges FORCE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation ON lead_charges - USING (tenant_id = current_setting('app.current_tenant_id')::bigint) - WITH CHECK (tenant_id = current_setting('app.current_tenant_id')::bigint); + USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint) + WITH CHECK (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint); -- v8.15 (Plan 1/5 Task 4): SELECT + INSERT для tenant-приложения (append-only ledger). -- UPDATE/DELETE недопустимы — append-only гарантия для биллинга/аудита. @@ -2238,7 +2239,7 @@ CREATE INDEX project_routing_snapshots_signal_idx ALTER TABLE project_routing_snapshots ENABLE ROW LEVEL SECURITY; CREATE POLICY project_routing_snapshots_tenant_isolation ON project_routing_snapshots - USING (tenant_id = current_setting('app.current_tenant_id', true)::bigint); + USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint); GRANT SELECT, INSERT, UPDATE ON project_routing_snapshots TO crm_app_user; GRANT SELECT, INSERT, UPDATE, DELETE ON project_routing_snapshots TO crm_supplier_worker; @@ -2268,7 +2269,7 @@ CREATE TABLE supplier_lead_deliveries ( ALTER TABLE supplier_lead_deliveries ENABLE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation ON supplier_lead_deliveries - USING (tenant_id = current_setting('app.current_tenant_id')::bigint); + USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint); -- Явные GRANT'ы для 4 ролей (mirror webhook_dedup_keys): на prod таблица -- создаётся crm_supplier_worker, default privileges не наследуются от @@ -3112,7 +3113,7 @@ VALUES -- ============================================================================= -- Шаблон политики (применяется ко всем tenant-таблицам): --- USING (tenant_id = current_setting('app.current_tenant_id')::bigint) +-- USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint) ALTER TABLE users ENABLE ROW LEVEL SECURITY; ALTER TABLE projects ENABLE ROW LEVEL SECURITY; @@ -3156,50 +3157,58 @@ ALTER TABLE project_user_assignments ENABLE ROW LEVEL SECURITY; -- v8.5 ( ALTER TABLE webhook_dedup_keys ENABLE ROW LEVEL SECURITY; -- v8.6 (CTO-17) ALTER TABLE in_app_notifications ENABLE ROW LEVEL SECURITY; -- v8.10 (P0 этап 2) +-- v8.57 (26.06.2026): все политики приведены к NULLIF(current_setting('app.current_tenant_id', true), '')::bigint — +-- защита от 22P02 (GUC='') и 42704 (GUC не задан) на Managed PG под PgBouncer (инцидент входа 26.06). +-- Bootstrap-таблицы (users, auth_log, email_verifications, user_recovery_codes, user_sessions) дополнительно +-- получают "NULLIF(...) IS NULL OR ..." — они читаются/пишутся ДО tenant-контекста (auth-роуты без 'tenant' +-- middleware). При ЗАДАННОМ tenant изоляция не меняется. Подробности — db/migrations/2026_06_26_153000_rls_nullif_guc_hardening.sql. -- Базовая политика для таблиц с прямым tenant_id -CREATE POLICY tenant_isolation ON users USING (tenant_id = current_setting('app.current_tenant_id')::bigint); -CREATE POLICY tenant_isolation ON projects USING (tenant_id = current_setting('app.current_tenant_id')::bigint); -CREATE POLICY tenant_isolation ON deals USING (tenant_id = current_setting('app.current_tenant_id')::bigint); -CREATE POLICY tenant_isolation ON tenant_status_overrides USING (tenant_id = current_setting('app.current_tenant_id')::bigint); -CREATE POLICY tenant_isolation ON tenant_custom_domains USING (tenant_id = current_setting('app.current_tenant_id')::bigint); -CREATE POLICY tenant_isolation ON api_keys USING (tenant_id = current_setting('app.current_tenant_id')::bigint); -CREATE POLICY tenant_isolation ON push_subscriptions USING (tenant_id = current_setting('app.current_tenant_id')::bigint); -CREATE POLICY tenant_isolation ON comment_templates USING (tenant_id = current_setting('app.current_tenant_id')::bigint); -CREATE POLICY tenant_isolation ON deal_tags USING (tenant_id = current_setting('app.current_tenant_id')::bigint); -CREATE POLICY tenant_isolation ON import_log USING (tenant_id = current_setting('app.current_tenant_id')::bigint); -CREATE POLICY tenant_isolation ON import_unknown_statuses USING (tenant_id = current_setting('app.current_tenant_id')::bigint); -CREATE POLICY tenant_isolation ON activity_log USING (tenant_id = current_setting('app.current_tenant_id')::bigint); -CREATE POLICY tenant_isolation ON tenant_operations_log USING (tenant_id = current_setting('app.current_tenant_id')::bigint); -- v8.31: перенесено из inline +CREATE POLICY tenant_isolation ON users USING (NULLIF(current_setting('app.current_tenant_id', true), '') IS NULL OR tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint); +CREATE POLICY tenant_isolation ON projects USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint); +CREATE POLICY tenant_isolation ON deals USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint); +CREATE POLICY tenant_isolation ON tenant_status_overrides USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint); +CREATE POLICY tenant_isolation ON tenant_custom_domains USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint); +CREATE POLICY tenant_isolation ON api_keys USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint); +CREATE POLICY tenant_isolation ON push_subscriptions USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint); +CREATE POLICY tenant_isolation ON comment_templates USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint); +CREATE POLICY tenant_isolation ON deal_tags USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint); +CREATE POLICY tenant_isolation ON import_log USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint); +CREATE POLICY tenant_isolation ON import_unknown_statuses USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint); +CREATE POLICY tenant_isolation ON activity_log USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint); +CREATE POLICY tenant_isolation ON tenant_operations_log USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint); -- v8.31: перенесено из inline -- webhook_log / rejected_deals_log policies удалены в v8.35 (таблицы удалены) -CREATE POLICY tenant_isolation ON failed_webhook_jobs USING (tenant_id = current_setting('app.current_tenant_id')::bigint); -CREATE POLICY tenant_isolation ON tariff_subscriptions USING (tenant_id = current_setting('app.current_tenant_id')::bigint); -CREATE POLICY tenant_isolation ON saas_invoices USING (tenant_id = current_setting('app.current_tenant_id')::bigint); -CREATE POLICY tenant_isolation ON saas_upd_documents USING (tenant_id = current_setting('app.current_tenant_id')::bigint); -CREATE POLICY tenant_isolation ON saas_transactions USING (tenant_id = current_setting('app.current_tenant_id')::bigint); -CREATE POLICY tenant_isolation ON refund_requests USING (tenant_id = current_setting('app.current_tenant_id')::bigint); -CREATE POLICY tenant_isolation ON balance_transactions USING (tenant_id = current_setting('app.current_tenant_id')::bigint); -CREATE POLICY tenant_isolation ON report_jobs USING (tenant_id = current_setting('app.current_tenant_id')::bigint); -CREATE POLICY tenant_isolation ON tenant_consents USING (tenant_id = current_setting('app.current_tenant_id')::bigint); -CREATE POLICY tenant_isolation ON pd_processing_log USING (tenant_id = current_setting('app.current_tenant_id')::bigint); -CREATE POLICY tenant_isolation ON project_limit_adjustments USING (tenant_id = current_setting('app.current_tenant_id')::bigint); -- v8.2 -CREATE POLICY tenant_isolation ON outbound_webhook_subscriptions USING (tenant_id = current_setting('app.current_tenant_id')::bigint); -- v8.4 -CREATE POLICY tenant_isolation ON outbound_webhook_deliveries USING (tenant_id = current_setting('app.current_tenant_id')::bigint); -- v8.4 -CREATE POLICY tenant_isolation ON in_app_notifications USING (tenant_id = current_setting('app.current_tenant_id')::bigint); -- v8.10 +CREATE POLICY tenant_isolation ON failed_webhook_jobs USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint); +CREATE POLICY tenant_isolation ON tariff_subscriptions USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint); +CREATE POLICY tenant_isolation ON saas_invoices USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint); +CREATE POLICY tenant_isolation ON saas_upd_documents USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint); +CREATE POLICY tenant_isolation ON saas_transactions USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint); +CREATE POLICY tenant_isolation ON refund_requests USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint); +CREATE POLICY tenant_isolation ON balance_transactions USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint); +CREATE POLICY tenant_isolation ON report_jobs USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint); +CREATE POLICY tenant_isolation ON tenant_consents USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint); +CREATE POLICY tenant_isolation ON pd_processing_log USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint); +CREATE POLICY tenant_isolation ON project_limit_adjustments USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint); -- v8.2 +CREATE POLICY tenant_isolation ON outbound_webhook_subscriptions USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint); -- v8.4 +CREATE POLICY tenant_isolation ON outbound_webhook_deliveries USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint); -- v8.4 +CREATE POLICY tenant_isolation ON in_app_notifications USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint); -- v8.10 -- v8.2: project_suppliers — фильтр через JOIN на projects (tenant_id у проекта, не у связи) CREATE POLICY tenant_isolation ON project_suppliers USING ( - project_id IN (SELECT id FROM projects WHERE tenant_id = current_setting('app.current_tenant_id')::bigint) + project_id IN (SELECT id FROM projects WHERE tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint) ); -- Для user_*-таблиц фильтр через JOIN на users CREATE POLICY tenant_isolation ON user_recovery_codes USING ( - user_id IN (SELECT id FROM users WHERE tenant_id = current_setting('app.current_tenant_id')::bigint) + NULLIF(current_setting('app.current_tenant_id', true), '') IS NULL + OR user_id IN (SELECT id FROM users WHERE tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint) ); CREATE POLICY tenant_isolation ON user_sessions USING ( - user_id IN (SELECT id FROM users WHERE tenant_id = current_setting('app.current_tenant_id')::bigint) + NULLIF(current_setting('app.current_tenant_id', true), '') IS NULL + OR user_id IN (SELECT id FROM users WHERE tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint) ); CREATE POLICY tenant_isolation ON email_verifications USING ( - user_id IN (SELECT id FROM users WHERE tenant_id = current_setting('app.current_tenant_id')::bigint) + NULLIF(current_setting('app.current_tenant_id', true), '') IS NULL + OR user_id IN (SELECT id FROM users WHERE tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint) ); -- saas_invoice_items: фильтр через invoice_id @@ -3207,44 +3216,45 @@ CREATE POLICY tenant_isolation ON email_verifications USING ( -- строку invoice_item ссылающуюся на чужой invoice. До v8.5 защита только -- на USING (SELECT/UPDATE filter), INSERT мог пройти если invoice_id — чужой. CREATE POLICY tenant_isolation ON saas_invoice_items USING ( - invoice_id IN (SELECT id FROM saas_invoices WHERE tenant_id = current_setting('app.current_tenant_id')::bigint) + invoice_id IN (SELECT id FROM saas_invoices WHERE tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint) ) WITH CHECK ( - invoice_id IN (SELECT id FROM saas_invoices WHERE tenant_id = current_setting('app.current_tenant_id')::bigint) + invoice_id IN (SELECT id FROM saas_invoices WHERE tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint) ); -- v8.4 hotfix: deal_tag_pivot — фильтр через tag_id → deal_tags(tenant_id) -- v8.5 (OPEN-И-14): добавлено WITH CHECK — нельзя пометить deal чужим тегом. CREATE POLICY tenant_isolation ON deal_tag_pivot USING ( - tag_id IN (SELECT id FROM deal_tags WHERE tenant_id = current_setting('app.current_tenant_id')::bigint) + tag_id IN (SELECT id FROM deal_tags WHERE tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint) ) WITH CHECK ( - tag_id IN (SELECT id FROM deal_tags WHERE tenant_id = current_setting('app.current_tenant_id')::bigint) + tag_id IN (SELECT id FROM deal_tags WHERE tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint) ); -- v8.5 (CTO-16): project_user_assignments — фильтр через project_id → projects(tenant_id). -- WITH CHECK на INSERT/UPDATE — нельзя назначить менеджера в чужой проект. CREATE POLICY tenant_isolation ON project_user_assignments USING ( - project_id IN (SELECT id FROM projects WHERE tenant_id = current_setting('app.current_tenant_id')::bigint) + project_id IN (SELECT id FROM projects WHERE tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint) ) WITH CHECK ( - project_id IN (SELECT id FROM projects WHERE tenant_id = current_setting('app.current_tenant_id')::bigint) + project_id IN (SELECT id FROM projects WHERE tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint) ); -- v8.6 (CTO-17): webhook_dedup_keys — tenant-уровневая по tenant_id напрямую. -- WITH CHECK на INSERT/UPDATE — нельзя зарегистрировать дедуп-ключ в чужом тенанте -- (defense-in-depth поверх tenant_id NOT NULL + FK на deals). CREATE POLICY tenant_isolation ON webhook_dedup_keys USING ( - tenant_id = current_setting('app.current_tenant_id')::bigint + tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint ) WITH CHECK ( - tenant_id = current_setting('app.current_tenant_id')::bigint + tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint ); -- auth_log: tenant_user и saas_admin строки разные. Здесь — только tenant_user. CREATE POLICY tenant_isolation ON auth_log USING ( - actor_type = 'tenant_user' - AND tenant_id = current_setting('app.current_tenant_id')::bigint + NULLIF(current_setting('app.current_tenant_id', true), '') IS NULL + OR (actor_type = 'tenant_user' + AND tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint) ); -- Таблицы supplier_lead_costs, supplier_invoices, suppliers — НЕ tenant-уровневые @@ -3567,7 +3577,7 @@ COMMENT ON FUNCTION set_pd_subject_request_deadline() IS -- CREATE INDEX ON call_recordings (tenant_id, deal_id, call_started_at DESC); -- ALTER TABLE call_recordings ENABLE ROW LEVEL SECURITY; -- CREATE POLICY tenant_isolation ON call_recordings --- USING (tenant_id = current_setting('app.current_tenant_id')::bigint); +-- USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint); -- ============================================================================= @@ -3605,7 +3615,7 @@ CREATE TABLE balance_freeze_log ( ALTER TABLE balance_freeze_log ENABLE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation ON balance_freeze_log - USING (tenant_id = current_setting('app.current_tenant_id', true)::bigint); + USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint); CREATE INDEX balance_freeze_log_tenant_idx ON balance_freeze_log (tenant_id, created_at DESC);