Vlad VityukІнтерактивні пояснення

Коли адміни ламають усе

Ти хочеш, щоб адміни читали кожен документ. Очевидний хід: написати політику на documents, яка каже «видно, якщо ти власник або ти адмін», і шукати «адміна» в таблиці user_roles.

Почни з самої таблиці. Коли RLS вимкнено, кожен, хто може зробити запит, бачить усе. Перемкни увімк — і, доки немає політики, таблиця гасне: RLS за замовчуванням забороняє. (Це не те саме, що «без RLS» — це deny-all, opt-in; ти повертаєш доступ назад рядок за рядком.)

Таблиця — і вимикач RLS
запускаю Postgres…
Вимк: кожен рядок публічний. Увімк без політики: не повертається нічого — RLS за замовчуванням забороняє (deny-all ≠ «без RLS»). Політика повертає доступ назад, рядок за рядком.

Політика повертає рядки назад. RLS увімкнено, тож таблиця спершу порожня — натисни Apply, щоб додати політику нижче, і дивись, як повертаються лише твої рядки. Зміни її й застосуй знову, щоб побачити, як набір міняється:

Твоя перша політика
запускаю Postgres…
USING вирішує, які рядки тобі видно. Зміни політику й натисни Apply — дивись, як міняються рядки.

Та сама політика виглядає інакше залежно від того, хто питає. Перемикай роль і дивись, як рядки з'являються і зникають:

Та сама політика, інші очі
запускаю Postgres…
Одна політика, три ролі. anon не має особи й не бачить нічого; authenticated — це ти, бачиш свої рядки; service_role оминає RLS і бачить усе — саме цією роллю під'єднується твій застосунок, і саме так дані зливаються повз RLS, якщо застосунок забирає зайве. Перемикай роль і дивись, як міняються рядки.

Саме остання роль — пастка. Перемкни на service_role — і повертаються всі рядки: вона повністю оминає RLS. Саме цією роллю під'єднується твій застосунок, тож якщо він забирає зайве й віддає рядки клієнту, політики бази вже нічого не захистили.

У RLS дві половини. USING вирішує, які рядки ти можеш читати; WITH CHECK — які рядки ти можеш записувати. Спробуй вставити рядок, що належить комусь іншому — WITH CHECK його відхилить:

USING проти WITH CHECK
запускаю Postgres…
USING фільтрує, які рядки ти можеш читати; WITH CHECK перевіряє рядки, які ти намагаєшся записати. Встав рядок, що належить комусь іншому: для запису USING не діє, але WITH CHECK його відхиляє.

У тестах усе працює. Потім ти вмикаєш RLS і для user_roles — бо ця таблиця чутлива — і кожен запит до documents починає падати з 500. Ніщо зі змін у documents цього не пояснює.

Причина — один факт про RLS: політика це не фільтр, що спрацьовує один раз згори. Вона перевіряється на кожному читанні таблиці — включно з тим читанням, яке політика робить усередині себе. Перемкни RLS на user_roles нижче, щоб відтворити момент, коли все зламалось.

коли воно зламалось
SELECT * FROM documents

у documents увімкнено RLS; щоб перевірити «адмін», його політика читає user_roles.

RLS на user_roles:
  1. політика documents працює → щоб перевірити адміна, читає user_roles
  2. RLS на user_roles вимкнено → читання не перевіряється політикою → воно відповідає
  3. ✓ політика documents розв'язується → рядок повертається

Ось що працювало в твоїх тестах: RLS на user_roles був вимкнений, тож внутрішнє читання не перевірялося — ланцюг тут і закінчився. (Вимк ≠ deny-all: коли RLS вимкнено, немає політики, яку треба застосовувати.)

Ось що насправді робить база. Розгортай вимогу рівень за рівнем і дивись, що довелося б прочитати, щоб задовольнити політику. RLS виконується на кожному доступі до таблиці — включно з таблицями, які твоя політика читає всередині свого підзапиту.

обчислення політики
політика:
ДОСЛІДЖЕННЯ · що вимагала б ця політика? (гіпотетично)
глибина 1
Ти виконуєш: SELECT * FROM documents
глибина 2
Перш ніж повернути рядок, мусить виконатись політика documents: ти власник АБО ти адмін. Щоб перевірити «адмін», вона читає user_roles.
create policy doc_access on documents for select using (
owner_id = auth.uid()
or exists (select 1 from user_roles ur
where ur.user_id = auth.uid() and ur.role = 'admin')
);
глибина 3
Але читання user_roles запускає власну політику user_roles — а та політика знову читає user_roles.
create policy ur_self on user_roles for select using (
exists (select 1 from user_roles where user_id = auth.uid())← читає user_roles, що запускає цю ж політику
);
… ∞ …
Розгортай вимогу, рівень за рівнем — і поміть, що вона ніколи не доходить дна. Тоді подивись, що насправді робить Postgres.

Політика на documents мусить зазирнути в user_roles. Але в user_roles є власна політика — яка знову читає user_roles. Це читання потребує політики, яка читає таблицю, яка потребує політики. Воно ніколи не доходить до дна, тож Postgres навіть не намагається: він статично виявляє цикл і аварійно завершується з 42P17.

Виправлення. Перемкни тумблер на через is_admin(). Тепер перевірка адміна йде через функцію, позначену security definer.

security definer — це не окрема магічна штука й не service_role. Це режим функції. За замовчуванням функція виконується з правами того, хто її викликав (security invoker) — і RLS на нього діє. А security definer каже навпаки: виконуй з правами того, хто функцію створив, — її власника. Це як sudo: ти запускаєш, а всередині працюють права власника. Власник тут привілейований, RLS на нього не поширюється — тож коли is_admin() читає user_roles, це читання не проходить через політику, і цикл не виникає. Санкціонований запасний вихід: довірений помічник, якому дозволено зазирнути, не проходячи перевірку щоразу.

Рекурсія — це гучний збій, 500, який не проґавиш. Тихіший — це ціна: політика не безкоштовна. Її вираз USING обчислюється заново на кожен прочитаний рядок. Якщо там виклик функції на кшталт auth.uid(), то на таблиці в мільйон рядків він спрацює мільйон разів — і щоразу поверне те саме значення. Марна робота.

Виправлення — одна з найвідоміших порад про RLS: загорни виклик у (select auth.uid()). Тоді Postgres бачить, що значення однакове для всього запиту, рахує його один раз наперед і далі просто підставляє готову константу. (Цей «один раз наперед» Postgres у плані запиту й називає InitPlan.) Ті самі рядки, той самий результат — зовсім інший план. Перемкни предикат і потягни кількість рядків:

Ціна: політика виконується на кожному рядку
запускаю Postgres…
USING політики виконується раз на кожен прочитаний рядок. Перемикай предикат і тягни кількість рядків — дивись, як міняються справжній план і час.