golang

Как перестать писать WHERE tenant_id и отдать безопасность базе (PostgreSQL RLS в Go)?

  • четверг, 22 января 2026 г. в 00:00:07
https://habr.com/ru/articles/987364/

В одном из прошлых проектов случился «кошмар техлида»: в суматохе хотфикса было забыто добавление фильтра 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(), но вы проверите только свой мок. Вы не проверите:

  1. Действительно ли политика применилась?

  2. Не протекли ли данные через индекс?

Остается один путь - честные интеграционные тесты на живой базе.

Раньше мы поднимали локальный 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

-

Работает идеально с is_local=true

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) приложение, если оно пытается работать без тенанта.