Реалізація Row-Level Security для мультитенантного веб-застосунку

Наша компанія займається розробкою, підтримкою та обслуговуванням сайтів будь-якої складності. Від простих односторінкових сайтів до масштабних кластерних систем, побудованих на мікро сервісах. Досвід розробників підтверджено сертифікатами від вендорів.
Розробка та обслуговування будь-яких видів сайтів:
Інформаційні сайти або веб-програми
Сайти візитки, landing page, корпоративні сайти, онлайн каталоги, квіз, промо-сайти, блоги, ресурси новин, інформаційні портали, форуми, агрегатори
Сайти або веб-програми електронної комерції
Інтернет-магазини, B2B-портали, маркетплейси, онлайн-обмінники, кешбек-сайти, біржі, дропшиппінг-платформи, парсери товарів
Веб-програми для управління бізнес-процесами
CRM-системи, ERP-системи, корпоративні портали, системи управління виробництвом, парсери інформації
Сайти або веб-програми електронних послуг
Дошки оголошень, онлайн-школи, онлайн-кінотеатри, конструктори сайтів, портали надання електронних послуг, відеохостинги, тематичні портали

Це лише деякі з технічних типів сайтів, з якими ми працюємо, і кожен із них може мати свої специфічні особливості та функціональність, а також бути адаптованим під конкретні потреби та цілі клієнта.

Пропоновані послуги
Показано 1 з 1 послугУсі 2065 послуг
Реалізація Row-Level Security для мультитенантного веб-застосунку
Складна
~3-5 робочих днів
Часті питання
Наші компетенції:
Етапи розробки
Останні роботи
  • image_website-b2b-advance_0.png
    Розробка сайту компанії B2B ADVANCE
    1262
  • image_web-applications_feedme_466_0.webp
    Розробка веб-додатків для компанії FEEDME
    1171
  • image_websites_belfingroup_462_0.webp
    Розробка веб-сайту для компанії БЕЛФІНГРУП
    874
  • image_ecommerce_furnoro_435_0.webp
    Розробка інтернет магазину для компанії FURNORO
    1094
  • image_crm_enviok_479_0.webp
    Розробка веб-додатків для компанії Enviok
    831
  • image_bitrix-bitrix-24-1c_fixper_448_0.png
    Розробка веб-сайту для компанії ФІКСПЕР
    851

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 тижні.