Как перестать писать WHERE tenant_id и отдать безопасность базе (PostgreSQL RLS в Go)?
- четверг, 22 января 2026 г. в 00:00:07
В одном из прошлых проектов случился «кошмар техлида»: в суматохе хотфикса было забыто добавление фильтра WHERE tenant_id = ? в одну из ручек API. В итоге один клиент увидел отчеты другого. Все быстро откатили, но я навсегда запомнил то холодное чувство в животе.
Когда начали проектировать архитектуру следующего проекта, я понял, что полагаться на внимательность разработчиков на код-ревью - это тупик. Рано или поздно кто-то устанет, ошибется, и данные снова протекут.
Искал способ гарантировать изоляцию данных так, чтобы ее физически нельзя было забыть.
Почему стандартные решения не подошли?
Перебрал классическую тройку вариантов, и у каждого нашлись фатальные минусы для задачи:
1. Логическая изоляция (WHERE в коде)?
Как это работает: Тысячи строк кода, и в каждом запросе ты обязан помнить про tenant_id.
Проблема: Человеческий фактор. Это бомба замедленного действия.
2. Схема на клиента (Schema-per-tenant)
Как это работает: У каждого клиента своя схема (schema_01, schema_02...).
Проблема: Это работает, пока клиентов 100. Когда их становится 10 000, база начинает задыхаться.
Детали: Проблема даже не в миграциях, а в файловой системе. 10 000 клиентов × 50 таблиц = 500 000 файлов. Postgres (и Linux) сходят с ума от такого количества открытых дескрипторов, а VACUUM превращается в ад.
3. Отдельная БД на клиента
Как это работает: Полная физическая изоляция.
Проблема: Ценник на инфраструктуру. Держать тысячи коннектов или инстансов RDS - экономическое самоубийство для стартапа.
Тогда посмотрел в сторону PostgreSQL Row Level Security (RLS). Честно говоря, поначалу было страшно. Отдавать логику безопасности "черному ящику" внутри БД казалось рискованным. Плюс, все вокруг пугали: "RLS убьет производительность".
Но решил попробовать.
Как это выглядит в коде?
Идея проста - приложение вообще не пишет фильтры. База данных сама знает, кто делает запрос, и "на лету" подставляет нужные условия.
Вот как это было реализовано.
1. SQL-миграция: включаем "Паранойю"
Вместо сотен проверок в Go, правила пишутся один раз в миграции.
-- Таблица документов (бизнес-данные)
CREATE TABLE documents (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
title TEXT NOT NULL,
body TEXT
);
-- Включаем RLS
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
-- ВАЖНО: Force RLS
-- Без этой строки владелец таблицы (обычно это юзер, под которым ходит приложение)
-- будет видеть ВСЁ, игнорируя политики.
ALTER TABLE documents FORCE ROW LEVEL SECURITY;
-- Политика: "Показывай только то, что принадлежит текущему тенанту"
CREATE POLICY tenant_isolation_policy ON documents
FOR ALL
USING (tenant_id = current_setting('app.current_tenant')::uuid);2. Middleware: контекст запроса
На уровне HTTP просто достаем ID тенанта и кладем его в контекст. Тут нет магии.
func TenantMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// ВАЖНО ДЛЯ ПРОДАКШЕНА:
// В реальном проекте tenant_id мы достаем из claims JWT-токена (sub/tenant_id),
// который подписан и валидирован.
// Доверять заголовку "X-Tenant-ID" от клиента нельзя - это дыра в безопасности.
// Для простоты примера в статье оставлен заголовок.
tenantStr := r.Header.Get("X-Tenant-ID")
// Валидация...
if _, err := uuid.Parse(tenantStr); err != nil {
http.Error(w, "invalid tenant id", http.StatusBadRequest)
return
}
// Кладем в контекст (в проде используем кастомный тип ключа)
ctx := context.WithValue(r.Context(), "tenant_id", tenantStr)
next.ServeHTTP(w, r.WithContext(ctx))
})
}3. Работа с БД: где "подружились" Go и Postgres
Самый тонкий момент - передать tenant_id из Go в переменную сессии Postgres.
Использовался пулер соединений (pgxpool). Проблема в том, что если сделать SET app.current_tenant = ..., эта настройка может "прилипнуть" к соединению. Когда соединение вернется в пул, а потом достанется другому юзеру, он получит чужие права.
Решение найдено через транзакции и set_config с параметром is_local=true.
type Postgres struct {
Pool *pgxpool.Pool
}
// Обертка для всех транзакций
func (p *Postgres) RunInTx(ctx context.Context, fn func(ctx context.Context, tx pgx.Tx) error) error {
// 1. Hard check: Если tenant_id нет, лучше упасть в панику, чем слить данные.
// 1. Hard check: Если tenant_id нет, лучше упасть в панику, чем слить данные.
// Если мы просто продолжим, то current_setting('...', true) вернет NULL,
// и запрос вернет 0 строк (из-за политики), что может скрыть баг пропущенного мидлвари.
// Поэтому Panic здесь оправдан как fail-fast механизм разработки.
tenantID, ok := ctx.Value("tenant_id").(string)
if !ok || tenantID == "" {
panic("CRITICAL: DB transaction without tenant_id!")
}
// Примечание: В реальном коде проверяется, есть ли уже транзакция в ctx,
// чтобы поддержать вложенные вызовы (Savepoints).
// Для статьи код упрощен до плоской структуры.
tx, err := p.Pool.Begin(ctx)
if err != nil { return err }
defer tx.Rollback(ctx) // Всегда откатываем, если не было commit
// 2. Установка переменной сессии
// Третий параметр 'true' означает, что настройка живет ТОЛЬКО до конца транзакции.
// Даже если коннект вернется в пул "грязным", Postgres сбросит этот параметр.
, err = tx.Exec(ctx, "SELECT setconfig('app.current_tenant', $1, true)", tenantID)
if err != nil { return err }
// 3. Выполняется бизнес-логика
if err := fn(ctx, tx); err != nil { return err }
return tx.Commit(ctx)
}4. Бизнес-логика: Наслаждение чистотой
Теперь код выглядит так. Никаких WHERE.
func ListDocuments(ctx context.Context, db *Postgres) ([]Document, error) {
var docs []Document
// Просто делаешь SELECT *
// Postgres сам подставит "WHERE tenant_id = ..."
err := db.RunInTx(ctx, func(ctx context.Context, tx pgx.Tx) error {
// Используем библиотеку (например, pgxscan) для маппинга в структуру.
return pgxscan.Select(ctx, tx, &docs, "SELECT * FROM documents ORDER BY created_at DESC")
})
return docs, err
}А как это тестировать? (Testing the Un-testable)
Вот тут у меня случился первый ментальный блок.
Традиционные юнит-тесты с моками (go.mock и компания) против RLS абсолютно бесполезны.
Почему?
Потому что RLS - это логика, выполняемая внутри движка базы данных, в момент планирования запроса. Вы можете замокать вызов current_setting(), но вы проверите только свой мок. Вы не проверите:
Действительно ли политика применилась?
Не протекли ли данные через индекс?
Остается один путь - честные интеграционные тесты на живой базе.
Раньше мы поднимали локальный docker-compose up, и тесты стучались туда. Это был "ад зависимого состояния". Тест А изменил данные, Тест Б упал, потому что ожидал пустоту. Разработчики начинали комментировать тесты со словами "на CI починится".
Решение, которое спасло нервы - Testcontainers.
Для экосистемы Java это стандарт де-факто уже лет 5, в Go оно пришло позже, но сейчас работает стабильно.
Суть проста: каждый запуск тестов (или даже каждый отдельный тест-кейс) поднимает чистый, стерильный Docker-контейнер Postgres, накатывает туда ваши миграции, прогоняет сценарий и убивает контейнер.
// internal/infra/db/container_test.go
func SetupTestDB(t testing.TB) string {
ctx := context.Background()
// 1. Поднимаем "тяжелый" контейнер (делается 1 раз на пакет тестов)
// Используем тот же image, что и в проде!
// Для Remote Docker (CI/CD, Kubernetes) маунт файлов не сработает.
// Используем CopyFileToContainer:
pgContainer, err := postgres.Run(ctx,
"pgvector/pgvector:pg16",
postgres.WithDatabase("ronin_test"),
postgres.WithUsername("ronin"),
postgres.WithPassword("password"),
postgres.WithCopyFileToContainer(
"./migrations/",
"/docker-entrypoint-initdb.d/",
0644,
),
)
if err != nil {
t.Fatalf("failed to start postgres: %v", err)
}
// 2. Гарантируем очистку ресурсов
t.Cleanup(func() { pgContainer.Terminate(ctx) })
// 3. Накатываем структуру БД
connStr, _ := pgContainer.ConnectionString(ctx, "sslmode=disable")
applyMigrations(t, connStr)
return connStr
}И теперь сам тест выглядит лаконично. Никаких моков, только живая база:
func TestAlienAccess(t *testing.T) {
// 1. Получаем чистую базу (за 300мс)
dsn := SetupTestDB(t)
db := connect(dsn)
// 2. Создаем документ для Tenant A
docID := createDoc(db, "tenant-A", "Secret Plan")
// 3. Пытаемся прочитать под Tenant B
ctxB := context.WithValue(context.Background(), "tenant_id", "tenant-B")
_, err := db.GetDoc(ctxB, docID)
// 4. Проверяем, что базы для нас "не существует"
assert.ErrorIs(t, err, sql.ErrNoRows)
}
// Хелперы для тестов (createDoc, connect) опущены для краткости,
// но они должны использовать тот же пул соединений.Благодаря этому получилось выработать минимальную стратегию тестирования RLS, без которой код не попадает в main:
1. Тест "Свой среди своих":
Создаем Tenant A. Создаем документ. Читаем под контекстом Tenant A.
Ожидание: Документ найден.
2. Тест "Чужой":
Создаем документ под Tenant A. Пробуем прочитать его под контекстом Tenant B.
Ожидание: sql.ErrNoRows. Не ошибка доступа, а именно "нет данных". Это важно, чтобы не раскрыть даже факт существования записи.
3. Тест "Аноним / Ошибка контекста":
Пробуем сделать запрос вообще без установки app.current_tenant (симуляция бага в Middleware).
Ожидание: PANIC в Go-коде (наш выбор) или ошибка SQL "value too long / null constraint". Главное - не данные всех клиентов.
4. Тест "SQL-инъекция в ID":
В TenantID передаем ' OR '1'='1.
Ожидание: UUID-парсер Go должен упасть еще до похода в базу. Если дошли до базы - RLS должен упасть на касте типов.
Только такой набор тестов дает право спать спокойно. Если тест прошел - значит, защита работает на уровне бинарного протокола базы данных, а не на уровне "кажется, мы везде добавили WHERE".
Views и Security Barrier
Третий момент - это Views (представления). Допустим, вы хотите сделать публичную "витрину" документов.
CREATE VIEW public_docs AS SELECT * FROM docs WHERE is_public = true;Казалось бы, безопасно? Нет.
Хакер может сделать запрос: SELECT * FROM public_docs WHERE heavy_function(secret_field).
Если планировщик решит выполнить heavy_function до фильтрации is_public (а он может), ваша функция получит доступ к приватным данным.
Лечится это одной опцией: WITH (security_barrier). Она заставляет Postgres сначала отфильтровать строки внутри View, и только потом отдавать их наружу.
CREATE VIEW public_docs_view WITH (security_barrier) AS
SELECT ... FROM documents WHERE is_public = true;Запомните: любые View поверх RLS-таблиц должны быть с security_barrier. Иначе это решето.
Честные Бенчмарки и "Боль" RLS
Я решил не верить слухам и прогнал нагрузочные тесты с разными сценариями (Docker, ~10k записей). Результаты заставили задуматься.
Сценарий | Без RLS | С RLS | Оверхед | Вывод |
Simple Select (ID lookup) | 1.2 ms | 1.3 ms | +0.1 ms | Неощутимо, идеально для CRUD |
JOIN (Docs + Tenants) | 1.25 ms | 1.35 ms | +0.1 ms | Джойны работают отлично, планировщик умница |
Vector Search (HNSW) | 3-5 ms | 5-6 ms | ~10-20% | Терпимо, альтернатива (схемы) ест больше памяти |
GROUP BY (Count) | 0.8 ms | 1.95 ms | x2.4 | Postgres не может брать стату из метаданных |
ILIKE Search (No Index) | 9.0 ms | 9.1 ms | +1% | RLS ни в чем не виноват, SeqScan сам по себе тормоз |
ILIKE Search (GIN Index) | 0.2 ms | 1.3 ms | x6 | Оверхед проверки прав на каждой строке |
Transaction Pooling | OK | OK | - | Работает идеально с |
Statement Pooling | OK | BROKEN | N/A | Опасно! Контекст теряется, данные утекают. Не использовать |
Когда RLS становится проблемой
По таблице видно, что RLS - не серебряная пуля.
1. Агрегации (COUNT(*), MAX) убивают:
Обычно Postgres знает, сколько строк в таблице. С RLS он "слепнет", так как обязан проверить видимость каждой строки.
SELECT COUNT(*) с RLS на миллионе строк - это всегда Full Scan.
Решение: Денормализация (храните счетчики в отдельном поле) или используйте SECURITY DEFINER вьюхи для админских дашбордов.
Примечание: Денормализация - это не просто "добавить поле". Это триггеры на INSERT/DELETE и периодическая пересборка счетчиков. Не пытайтесь делать UPDATE tenant_stats прямо из кода приложения - получите дедлоки и рассинхрон. Пусть этим занимается база через триггеры.
2. Оверхед на быстрых индексах:
Посмотрите на кейс с GIN индексом. "Голый" поиск занимает 0.2ms. С RLS - 1.3ms. Мы платим 1.1ms чистого процессорного времени на проверку прав найденных строк.
Для Highload-поиска (тысячи RPS) RLS может стать узким горлышком.
3. Сложные политики = Смерть:
Политика с саб-запросом (tenant_id IN (SELECT...)) выполнится для каждой строки. С RLS правило одно: политика должна быть тупой (tenant_id = $1).
Единственный путь для Vector Search (AI)
Где RLS может быть безальтернативным - это работа с векторами (pgvector).
Если делать AI-поиск по базе:
В подходе Schemas: нужно строить HNSW-индекс для каждой схемы. Это сотни гигабайт RAM
В подходе RLS: строится один гигантский HNSW-индекс на поле embedding
Postgres эффективно использует индекс, а RLS накладывает фильтр поверх. Это позволяет масштабировать векторный поиск на тысячи клиентов без раздувания памяти.
Важный нюанс:
Убедитесь, что используете pgvector 0.8.0+.
В старых версиях RLS мог "скрывать" результаты (Index Scan находил топ-K ближайших вообще*, а потом RLS отфильтровывал чужие, оставляя вам 0 результатов).
В 0.8.0+ появился итеративный скан, который продолжает искать в индексе, пока не наберет нужное количество (LIMIT) видимых* пользователю записей.
Грабли (Куда без них?)
Не всё было гладко. Вот о чем стоит знать заранее:
Грабли №1: Вьюхи - это дыра по умолчанию
Это классика, на которой подрываются почти все.
Вы включили RLS. Вы проверили SELECT * FROM table - работает.
Вы создали View для удобства: CREATE VIEW docs_v AS SELECT * FROM documents.
Вы делаете селект из вьюхи... и видите все данные всех клиентов.
Почему?
В PostgreSQL (до 15 версии) вьюхи по умолчанию выполняются с правами Владельца (View Owner), а не текущего юзера.
Обычно владелец вьюхи - это тот же юзер, что создавал таблицы (migrator/admin). А владелец таблицы по умолчанию игнорирует RLS (если не включен FORCE ROW LEVEL SECURITY, о котором мы не зря говорили в начале).
Решения:
1. Postgres 15+ (Правильный путь):
Используйте security_invoker = true.
CREATE VIEW docs_v WITH (security_invoker = true) AS SELECT ...Это заставляет вьюху выполняться от имени того, кто делает запрос (Invoker), а не владельца (Definer). RLS применяется автоматически. Это Gold Standard для современных версий.
2. Для старых версий:
Всегда включайте ALTER TABLE ... FORCE ROW LEVEL SECURITY. Это заставит даже владельца подчиняться правилам.
3. SECURITY DEFINER (Осознанный риск):
Иногда вам нужно, чтобы вьюха игнорировала RLS (например, чтобы показать юзеру "Всего документов в системе: 100500", даже тех, к которым у него нет доступа).
Тогда вы создаете функцию или вьюху с SECURITY DEFINER. Но тут вы ходите по лезвию: вы обязаны вручную написать WHERE tenant_id = ... внутри, иначе сольете базу.
Грабли №2: Невидимая стена производительности (Leakproof)
Я долго не мог понять, почему простой поиск ILIKE кладет базу на лопатки, хотя индексы есть.
Симптомы:
SELECT ... WHERE title ILIKE '%foo%' без RLS работает 50мс.
Тот же запрос с RLS работает 5 секунд.
Причина:
Postgres боится выполнять ваши функции ДО проверки прав RLS. А вдруг функция search(text) отправляет этот текст хакерам по HTTP? Или кидает ошибку "Текст найден", выдавая наличие секретного документа?
Поэтому, если функция/оператор не помечены как LEAKPROOF (непротекающие), планировщик может (но не обязан) перестраховаться и сначала проверить tenant_id = ... для ВСЕХ строк таблицы (SeqScan!), и только потом применит фильтр.
Это не всегда происходит (в PG 12+ оптимизатор умный), но LEAKPROOF - это ваша гарантия стабильности плана выполнения, чтобы производительность не зависела от фазы луны и актуальности статистики.
Пруфы (EXPLAIN ANALYZE):
До (Seq Scan - 500ms):
Seq Scan on documents (cost=0.00..3452.00 rows=1)
Filter: ((tenant_id = '...'::uuid) AND (title ~~* '%foo%'::text))
Видите? Один общий фильтр. Индекс проигнорирован.
После (Index Scan - 1.3ms):
Index Scan using documents_title_trgm_idx on documents
Index Cond: (title ~~* '%foo%'::text)
Filter: (tenant_id = '...'::uuid)
Здесь база сначала нашла строки по индексу (Index Cond), а потом отфильтровала их по RLS (Filter). Победа.
Решение:
1. Использовать расширения с Leakproof-операторами (например, pg_trgm для GIN индексов часто безопасен).
2. Обернуть поиск в LEAKPROOF функцию:
CREATE OR REPLACE FUNCTION safe_title_search(term TEXT) RETURNS SETOF doc
LANGUAGE sql STABLE LEAKPROOF AS $$
SELECT * FROM doc WHERE title ILIKE '%' || term || '%'
$$;Это говорит базе: "Мамой клянусь, эта функция безопасна". И вуаля - снова Index Scan и 50мс.
Грабли №3: PgBouncer и режим Transaction Pooling
Многие боятся RLS, потому что думают, что он несовместим с пулерами соединений в режиме транзакций. А ведь именно этот режим нужен для хайлоада.
Проблема:
Если сделать обычный SET app.current_tenant = '...', переменная привязывается к физическому соединению. Когда транзакция завершается, PgBouncer возвращает "грязное" соединение в пул. Следующий запрос (от другого клиента!) может получить это соединение и увидеть чужие данные.
Решение:
Третий аргумент в set_config.
set_config('app.current_tenant', $1, true) - флаг is_local=true говорит Постгресу: "Эта переменная живет только до конца текущей транзакции".
Даже если PgBouncer вернет коннект в пул, Postgres гарантированно почистит контекст при COMMIT или ROLLBACK. Это делает RLS абсолютно безопасным для Transaction Pooling.
КРИТИЧНО: Statement Pooling режим (pool_mode = statement) абсолютно несовместим с RLS и set_config. В этом режиме транзакция может быть разорвана между разными соединениями, и контекст будет потерян или перепутан. Используйте только Transaction Pooling.
Грабли №4: Суперюзер и фоновые задачи
Важно помнить: RLS не применяется к суперпользователю (postgres) и владельцу таблицы (если не включен FORCE).
Но есть крон-джобы и админка, которым нужно видеть данные всех тенантов. Делать SET app.current_tenant в цикле для каждого тенанта - глупо.
Решение:
Была создана отдельная роль в БД для сервисных задач с атрибутом BYPASSRLS:
ALTER ROLE background_worker WITH BYPASSRLS;Получилось чистое разделение: app_user (клиентский API) жестко ограничен, а admin_user (воркеры) видит всё.
Грабли №5: Сложные политики
Если захочется сделать логику "Менеджер видит 5 клиентов", USING станет сложным и может начать кушать CPU. Главное - держать политики максимально простыми.
Как внедрить RLS на живом проекте (The 3-Phase Plan)
Самый частый вопрос, который мне задают: "Это все здорово для нового проекта, но у меня монолит на 500 таблиц и 100GB данных. Если я включу RLS, мы встанем колом?".
Внедрение RLS в легаси - это операция на открытом сердце. Одно неверное движение с ALTER TABLE, и прод лежит.
Но это возможно. Мы выработали безопасный 3-фазный план миграции, который позволяет внедрять RLS без даунтайма.
Фаза 1: Preparation (Permissive Mode)
Цель: Подготовить структуру БД, не ломая логику приложения.
1. Добавляем колонки: Добавляем tenant_id во все таблицы.
Важно: Не ставьте сразу NOT NULL, если таблица огромная. Это вызовет долгий лок и перестроение heap. Лучше добавить NULL, начать писать туда данные бэкендом, а потом в фоне заполнить старые записи.
2. Включаем RLS "для галочки": Создаем политики, которые разрешают все.
-- Включаем механизм, но не блокируем доступ
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
-- Политика "Добрый вахтер": Пускать всех
CREATE POLICY migration_phase_1 ON orders
FOR ALL
USING (true)
WITH CHECK (true);В этой фазе приложение работает как раньше. БД никак не ограничивает доступ. Но мы уже можем проверить, что ENABLE RLS не убил перформанс планировщика.
Фаза 2: Transition (Hybrid Mode)
Цель: Научить бэкенд работать с контекстом, сохраняя обратную совместимость.
Мы меняем Middleware приложения, чтобы он начал слать set_config('app.current_tenant', ...).
Но мы не можем гарантировать, что весь код уже обновлен. Где-то в глубине легаси может быть cron-скрипт или админка, которые работают без контекста.
Меняем политику на Гибридную:
DROP POLICY migration_phase_1 ON orders;
CREATE POLICY migration_phase_2 ON orders
FOR ALL
USING (
-- Вариант А: Есть контекст -> фильтруем жестко
(current_setting('app.current_tenant', true) IS NOT NULL
AND tenant_id = current_setting('app.current_tenant')::uuid)
OR
-- Вариант Б: Нет контекста (старый код) -> пускаем всех (или пишем в лог)
(current_setting('app.current_tenant', true) IS NULL)
);В этой фазе мы мониторим логи. Можно настроить аудит, чтобы видеть, какие запросы приходят без app.current_tenant, и планомерно их фиксить.
Предупреждение: Эта фаза логически опасна! Конструкция OR ... IS NULL означает, что если вы забудете передать контекст, запрос вернет всю базу, а не ошибку. Без строгого логирования (через RAISE WARNING внутри политики или pgAudit) этот режим - дыра в безопасности. Используйте его только кратковременно и под наблюдением.
Система всё еще уязвима, но мы уже тестируем механику изоляции для нового кода.
Фаза 3: Enforcement (Strict Mode)
Цель: Полная блокировка доступа без тенанта.
Когда мы уверены, что 100% легитимных запросов приходят с контекстом, мы захлопываем ловушку.
DROP POLICY migration_phase_2 ON orders;
-- Политика "Злой вахтер"
CREATE POLICY tenant_isolation_policy ON orders
FOR ALL
USING (tenant_id = current_setting('app.current_tenant')::uuid);
-- Финальный штрих: Запрещаем владельцам обходить правила
ALTER TABLE orders FORCE ROW LEVEL SECURITY;Если теперь какой-то забытый скрипт попробует прочитать данные без RunInTx, он получит пустую выборку (или ошибку, если настроить panics в коде). Поздравляю, вы мигрировали без единой минуты простоя.
Итог
Переход на RLS был не про "успешный успех", а про паранойю. Хочется спать спокойно.
В итоге получена система, где безопасность гарантируется на уровне инфраструктуры, а не на уровне внимательности джуниора. Код стал чище, тестов на авторизацию стало меньше, а производительность просела в пределах погрешности.
Если пишете мультитенантный бэкенд на Postgres - попробуйте RLS. Это не магия, это просто хороший инженерный инструмент, который незаслуженно обходят стороной.
В следующей статье расскажу, почему решил взрывать (panic) приложение, если оно пытается работать без тенанта.