TGViewer
.NET Разработчик .NET Разработчик @netdeveloperdiary · 6.74K subscribers
Post #3332 1.51K
День 2785. #ЗаметкиНаПолях
Обеспечиваем Изоляцию Тенантов с Помощью PostgreSQL
Механизм защиты на уровне строк (Row-Level Security, RLS) в PostgreSQL добавляет проверку на стороне БД поверх фильтров запросов EF Core. Он контролирует операции чтения и записи при условии, что приложение подключается под ролью, не являющейся владельцем таблицы, и устанавливает идентификатор тенанта при каждом открытии соединения.

Популярна рекомендация использовать глобальные фильтры запросов для реализации мультитенантности. EF Core добавляет условие WHERE tenant_id = @tenant к каждому генерируемому запросу, поэтому разработчикам не нужно помнить об этом предикате. Однако действие фильтра ограничено: SQL-команды, отправляемые через ExecuteSql, присоединённые сущности, а также запросы с вызовом IgnoreQueryFilters() остаются вне зоны его влияния.

Требуется дополнительный уровень проверки, не зависящий от того, помнит ли каждый разработчик о первом механизме. В PostgreSQL такая возможность уже встроена благодаря RLS.

Настройка правила в PostgreSql
Защита на уровне строк (RLS) представляет собой предикат, который PostgreSQL автоматически добавляет к каждой команде, выполняемой для таблицы (за исключением ролей, имеющих привилегию обхода RLS). Приложение не может «забыть» об этом условии, т.к. оно даже не видит его.

Суперпользователи, роли с атрибутом BYPASSRLS и владельцы таблиц обходят это ограничение. Во многих приложениях один и тот же пользователь БД выполняет миграции, обрабатывает запросы и является владельцем таблиц. Поэтому в этом примере приложение подключается под ролью app_user, которая не владеет объектами и имеет лишь необходимые права DML, тогда как миграции выполняются от имени отдельной роли-владельца. Такое разделение также предотвращает возможность выполнения команды DROP POLICY со стороны приложения. Вот пример политики для таблицы счетов (invoices):
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
ALTER TABLE invoices FORCE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON invoices
USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid)
WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid);

Условие USING определяет, к каким строкам возможен доступ при чтении, обновлении или удалении, а WITH CHECK отклоняет операции вставки или обновления, если новый tenant_id не совпадает с параметром сессии. Использование NULLIF гарантирует, что отсутствующий или пустой параметр не будет соответствовать ничему — именно такой вариант сбоя предпочтителен в данном случае. Опция FORCE распространяет действие политики и на владельца таблицы, предотвращая случайное изменение строк всех тенантов скриптами миграции.

Функция current_setting возвращает текущее значение параметра конфигурации.

Установка идентификатора тенанта для каждого соединения
Политика использует переменную сессии, поэтому приложение должно её устанавливать; логичнее всего делать это в начале обработки запроса. Однако такой подход быстро приводит к проблемам.
EF Core открывает соединение для выполнения команды и закрывает его по завершении, а Npgsql сбрасывает состояние сессии в пуле; в результате запрос, следующий за командой SET, выполняется в соединении, «не знающем» об этом тенанте. Отключение сброса с помощью параметра No Reset On Close=true делает ситуацию ещё хуже: следующий запрос «наследует» настройки предыдущего тенанта и получает доступ к строкам, которые не должен видеть.

Решение в том, чтобы устанавливать идентификатор тенанта при каждом открытии соединения — независимо от того, какое именно физическое соединение предоставляет Npgsql. Эту задачу выполняет перехватчик соединений (connection interceptor), использующий TenantContext для хранения идентификатора тенанта, авторизованного для текущего запроса:
public class TenantConnectionInterceptor(
TenantContext tenant)
: DbConnectionInterceptor
{
public override void ConnectionOpened(
DbConnection conn,
ConnectionEndEventData eventData)
{
using var cmd = Build(conn);
cmd.ExecuteNonQuery();
}

public override async Task ConnectionOpenedAsync(
DbConnection conn,
ConnectionEndEventData eventData,
CancellationToken ct = default)
{
await using var cmd = Build(conn);
await cmd.ExecuteNonQueryAsync(ct);
}

private DbCommand Build(DbConnection conn)
{
var cmd = conn.CreateCommand();
cmd.CommandText =
"SELECT set_config('app.tenant_id', @tenant, false)";

var prm = cmd.CreateParameter();
prm.ParameterName = "tenant";
prm.Value = tenant.TenantId?.ToString() ?? "";
cmd.Parameters.Add(prm);
return cmd;
}
}

Метод set_config принимает tenant в качестве параметра привязки, а значение false указывает на область видимости - сессия. Конструкция ?? "" обеспечивает блокировку запроса в случае отсутствия tenant.

Регистрируем сервисы с соответствующей областью видимости в Program.cs:
builder.Services.AddScoped<TenantContext>();
builder.Services.AddScoped<TenantConnectionInterceptor>();
builder.Services.AddDbContext<AppDbContext>(
(sp, opts) => opts
.UseNpgsql(connString)
.AddInterceptors(
sp.GetRequiredService<TenantConnectionInterceptor>())
);

Здесь используется AddDbContext, а не AddDbContextPool, т.к. контекст из пула сохранял бы TenantContext от первого запроса. При работе через PgBouncer в режиме пулинга транзакций идентификатор арендатора (tenant) необходимо задавать с помощью set_config(…, true) внутри явной транзакции.

Это влечет за собой дополнительные затраты в виде лишнего сетевого запроса при каждом открытии соединения — примерно 0,4 мс на запрос.

Попытка обойти ограничение
Отключим фильтр:
var affected = await db.Invoices
.IgnoreQueryFilters()
.Where(i => i.Id == otherTenantId)
.ExecuteUpdateAsync(
s => s.SetProperty(i => i.Status, "Cancelled"),
cancellationToken);
// affected == 0

Метод ExecuteUpdateAsync отправляет SQL-запрос немедленно, минуя механизмы отслеживания изменений и SaveChanges, поэтому фильтр не применяется. Поскольку строка принадлежит другому тенанту, политика скрывает её, и операция обновления не затрагивает ни одной записи.

Аналогично при работе с присоединённой (attached) сущностью. EF Core формирует запрос вида UPDATE invoices SET … WHERE id = @p0 на основе первичного ключа, политика добавляет свой предикат, и в итоге ни одна строка не удовлетворяет условиям. EF сообщает об этом как об исключении DbUpdateConcurrencyException (ошибка параллелизма), что выглядит как проигрыш в состоянии гонки при оптимистической блокировке, хотя данная строка изначально не была видна в текущей сессии.

Чтение без фильтрации по-прежнему возвращает только счета текущего тенанта, а попытка вставки записи с идентификатором другого тенанта завершается ошибкой с кодом SQLSTATE 42501.

Чего политика не может сделать, так это определить, какой тенант является «правильным». Если приложение ошибочно определит тенанта, политика применит ограничения именно для этого (неверного) тенанта.

Итого
Для многотенантных приложений рекомендуется использовать фильтр запросов EF в сочетании с политикой RLS. Фильтр включает идентификатор тенанта в SQL-запрос, благодаря чему планы выполнения остаются простыми, намерение разработчика ясно видно в коде, а накладные расходы во время выполнения отсутствуют. Политика же обеспечивает защиту даже в тех случаях, когда стандартный фильтр не задействован — например, при использовании IgnoreQueryFilters() для формирования административного отчёта.
Не забудьте предусмотреть роль, позволяющую обходить политику безопасности; это необходимо для выполнения резервного копирования и фоновых задач, затрагивающих данные разных тенантов.

Источник:
https://milanjovanovic.tech/blog/postgres-row-level-security-with-ef-core
  • 👍 7
More from @netdeveloperdiary
  1. Sep 26, 2026День 2796. #ЗаметкиНаПолях #AI Рабочий процесс с Copilot для .NET. Продолжение Начало Три…
  2. Sep 25, 2026День 2795. #ЗаметкиНаПолях #AI Рабочий процесс с Copilot для .NET. Начало Проблема с позиц…
  3. Sep 24, 2026День 2794. #Оффтоп #Здоровье Сегодня будет необычный пост. Завтра в Москве стартует конфер…
  4. Sep 23, 2026День 2793. #ЗаметкиНаПолях #SQL 10 Редких Возможностей SQL, Которые Стоит Знать Каждому. Ч…
  5. Sep 22, 2026День 2792. #ЗаметкиНаПолях #SQL 10 Редких Возможностей SQL, Которые Стоит Знать Каждому. Ч…
  6. Sep 21, 2026🔍Тестовое собеседование с Senior C# разработчиком уже завтра 22 сентября(уже завтра!) в 1…
Threads Profile ViewerView any public Threads profile without an account.Open ThreadLook →Writing with AI? Make it sound human.Metric37 rewrites AI drafts so they read naturally. Free AI detector, 1,500 words free.Try Metric37 →