У багатоорендному (multi-tenant) застосунку дані різних клієнтів-компаній лежать в одних таблицях з колонкою tenant_id. Найгірший можливий інцидент - витік даних між орендарями, тож ізоляція має бути системною, а не «не забути where в кожному запиті».
Рівень застосунку - глобальна область видимості Eloquent:
#[ScopedBy(TenantScope::class)]
class Invoice extends Model {}
class TenantScope implements Scope
{
public function apply(Builder $builder, Model $model): void
{
$builder->where($model->qualifyColumn('tenant_id'), Tenant::current()->id);
}
}
Кожен Eloquent-запит до моделі автоматично обмежено поточним орендарем. Плюс автоматичне заповнення tenant_id при створенні.
Слабкі місця глобальних областей:
- не працюють поза Eloquent:
DB::table('invoices'), сирі запити, звіти, деякі пакети; withoutGlobalScopes()- один виклик знімає захист;- черги, команди, планувальник - немає HTTP-запиту, і «поточний орендар» має бути встановлений явно (інакше область або падає, або - гірше - не застосовується);
- зв'язки й підзапити (
whereHas,join) можуть обійти область для пов'язаних таблиць; - унікальні індекси й
exists-валідація безtenant_id- перевірка «email уже зайнятий» по всіх орендарях розкриває існування даних.
Рівень бази - Row-Level Security (RLS) у PostgreSQL:
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
ALTER TABLE invoices FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON invoices
USING (tenant_id = current_setting('app.tenant_id')::bigint)
WITH CHECK (tenant_id = current_setting('app.tenant_id')::bigint);
DB::statement("SELECT set_config('app.tenant_id', ?, true)", [$tenant->id]); // true - лише в межах транзакції
Тепер будь-який запит - Eloquent, сирий SQL, звіт - бачить лише рядки поточного орендаря. Помилка в коді застосунку не перетвориться на витік.
Підводні камені RLS:
- власник таблиці й суперкористувачі обходять RLS за замовчуванням - потрібен
FORCE ROW LEVEL SECURITYі окрема роль застосунку без прав власника та безBYPASSRLS; - пули з'єднань (PgBouncer у режимі транзакцій, постійні з'єднання Octane): значення, встановлене для сесії, «перетікає» в наступний запит іншого орендаря. Тому -
set_config(..., true)/SET LOCALу транзакції на кожен запит; - міграції, адмінка й фонові задачі між орендарями потребують окремої ролі з явним обходом;
- продуктивність: умова політики додається до кожного запиту - потрібні індекси з
tenant_idпершою колонкою.
Підсумок: глобальна область - зручність і перший рівень, RLS - гарантія на рівні бази. Для даних, де витік між клієнтами неприпустимий, - обидва, плюс тести «орендар A не бачить даних орендаря B» для кожної моделі.
Докладніше в документації: PostgreSQL: Row Security Policies