Row-Level Security для мультиарендного приложения
Row-Level Security (RLS) — механизм PostgreSQL, позволяющий ограничить доступ к строкам таблицы прямо на уровне СУБД. Даже если приложение ошибётся и не передаст WHERE tenant_id = ?, PostgreSQL автоматически применит политику. RLS — второй контур защиты данных tenant'ов, независимый от ORM.
Как работает RLS
-- Включить RLS для таблицы
ALTER TABLE articles ENABLE ROW LEVEL SECURITY;
-- По умолчанию владелец таблицы (superuser) обходит RLS
-- Для проверки политик даже для owner:
ALTER TABLE articles FORCE ROW LEVEL SECURITY;
-- Политика: строка видна, если tenant_id совпадает с контекстом
CREATE POLICY tenant_isolation_select ON articles
FOR SELECT
USING (tenant_id = current_setting('app.current_tenant_id')::uuid);
-- Политика INSERT: нельзя вставить строку с чужим tenant_id
CREATE POLICY tenant_isolation_insert ON articles
FOR INSERT
WITH CHECK (tenant_id = current_setting('app.current_tenant_id')::uuid);
-- Объединить SELECT/INSERT/UPDATE/DELETE
CREATE POLICY tenant_isolation ON articles
USING (tenant_id = current_setting('app.current_tenant_id')::uuid)
WITH CHECK (tenant_id = current_setting('app.current_tenant_id')::uuid);
current_setting('app.current_tenant_id') — параметр сессии, который приложение устанавливает перед запросами.
Установка контекста в приложении
// Laravel — установка tenant context в middleware
class SetTenantContext
{
public function handle(Request $request, Closure $next): Response
{
$tenant = app('tenant'); // установлен ранее
// Установить PostgreSQL session variable
DB::statement(
"SELECT set_config('app.current_tenant_id', ?, false)",
[$tenant->id]
);
return $next($request);
}
}
false в третьем параметре set_config — значение применяется только в текущей транзакции. true — на всю сессию. Для connection pooling (PgBouncer) безопаснее false — при возврате соединения в пул значение сбрасывается.
PgBouncer и RLS
PgBouncer в transaction mode сбрасывает session-level переменные между транзакциями — это хорошо для безопасности, но требует устанавливать app.current_tenant_id в начале каждой транзакции:
DB::transaction(function () use ($tenant) {
DB::statement(
"SELECT set_config('app.current_tenant_id', ?, true)",
[$tenant->id]
);
// Все запросы внутри транзакции защищены RLS
Article::create([...]);
Comment::create([...]);
});
Разные политики для ролей
-- Суперадмин видит все строки
CREATE POLICY superadmin_all ON articles
FOR ALL
USING (current_setting('app.is_superadmin', true) = 'true');
-- Пользователи видят только свои и опубликованные статьи своего tenant'а
CREATE POLICY user_select ON articles
FOR SELECT
USING (
tenant_id = current_setting('app.current_tenant_id')::uuid
AND (
author_id = current_setting('app.current_user_id')::uuid
OR status = 'published'
)
);
-- Редакторы могут видеть черновики в своём tenant'е
CREATE POLICY editor_select ON articles
FOR SELECT
USING (
tenant_id = current_setting('app.current_tenant_id')::uuid
AND current_setting('app.current_role', true) = 'editor'
);
Несколько политик для одной команды (SELECT) объединяются через OR (permissive) или через AND (restrictive).
Restrictive политики
Permissive (по умолчанию): доступ разрешён, если ЛЮБАЯ политика выполнена. Restrictive: доступ разрешён, если ВСЕ restrictive политики выполнены.
-- Hard limit: удалённые аккаунты не видят ничего вне зависимости от других политик
CREATE POLICY no_deleted_tenant ON articles
AS RESTRICTIVE
USING (
NOT EXISTS (
SELECT 1 FROM tenants
WHERE id = current_setting('app.current_tenant_id')::uuid
AND deleted_at IS NOT NULL
)
);
Обход RLS для системных операций
-- Специальная роль без RLS (для миграций, аналитики)
CREATE ROLE app_migrations BYPASSRLS;
CREATE ROLE app_analytics BYPASSRLS;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO app_analytics;
-- В приложении: два пула соединений
-- app_user — обычная роль с RLS
-- app_analytics — роль с BYPASSRLS для аналитических запросов
// Laravel: отдельный connection для аналитики
DB::connection('analytics')->select('SELECT COUNT(*) FROM articles GROUP BY tenant_id');
Тестирование RLS политик
-- Тест от имени tenant_a
SET app.current_tenant_id = 'tenant-a-uuid';
SELECT count(*) FROM articles; -- должна видеть только статьи tenant_a
-- Тест попытки вставить в чужой tenant
SET app.current_tenant_id = 'tenant-a-uuid';
INSERT INTO articles (tenant_id, title)
VALUES ('tenant-b-uuid', 'Попытка взлома'); -- ERROR: new row violates row-level security policy
// PHPUnit — тест на утечку данных между tenant'ами
public function test_tenant_isolation(): void
{
$tenantA = Tenant::factory()->create();
$tenantB = Tenant::factory()->create();
Article::factory()->count(5)->create(['tenant_id' => $tenantA->id]);
Article::factory()->count(3)->create(['tenant_id' => $tenantB->id]);
// Авторизуемся как tenant A
DB::statement("SELECT set_config('app.current_tenant_id', ?, false)", [$tenantA->id]);
$articles = Article::all();
$this->assertCount(5, $articles);
$this->assertTrue($articles->every(fn($a) => $a->tenant_id === $tenantA->id));
}
Производительность
RLS добавляет условие к каждому запросу — индекс на tenant_id обязателен:
-- Составной индекс для типичных запросов
CREATE INDEX articles_tenant_status_idx ON articles(tenant_id, status);
CREATE INDEX articles_tenant_created_idx ON articles(tenant_id, created_at DESC);
-- Частичный индекс для активных записей
CREATE INDEX articles_active_idx ON articles(tenant_id, created_at DESC)
WHERE deleted_at IS NULL;
EXPLAIN ANALYZE покажет применение RLS-фильтра — убедитесь, что используется Index Scan, не Seq Scan.
Сроки
RLS политики на все таблицы, middleware установки контекста, тесты изоляции, обходная роль для миграций: 1 неделя. С restrictive политиками для удалённых аккаунтов, BYPASSRLS для аналитики, нагрузочное тестирование производительности: 2 недели.







