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: окремо з'єднання для аналітики
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 тижні.







