Коли адміни ламають усе
Ти хочеш, щоб адміни читали кожен документ. Очевидний хід: написати політику на
documents, яка каже «видно, якщо ти власник або ти адмін», і шукати «адміна» в
таблиці user_roles.
Почни з самої таблиці. Коли RLS вимкнено, кожен, хто може зробити запит, бачить усе. Перемкни увімк — і, доки немає політики, таблиця гасне: RLS за замовчуванням забороняє. (Це не те саме, що «без RLS» — це deny-all, opt-in; ти повертаєш доступ назад рядок за рядком.)
Політика повертає рядки назад. RLS увімкнено, тож таблиця спершу порожня — натисни Apply, щоб додати політику нижче, і дивись, як повертаються лише твої рядки. Зміни її й застосуй знову, щоб побачити, як набір міняється:
Та сама політика виглядає інакше залежно від того, хто питає. Перемикай роль і дивись, як рядки з'являються і зникають:
Саме остання роль — пастка. Перемкни на service_role — і повертаються всі рядки: вона повністю оминає RLS. Саме цією роллю під'єднується твій застосунок, тож якщо він забирає зайве й віддає рядки клієнту, політики бази вже нічого не захистили.
У RLS дві половини. USING вирішує, які рядки ти можеш читати; WITH CHECK — які рядки ти можеш записувати. Спробуй вставити рядок, що належить комусь іншому — WITH CHECK його відхилить:
У тестах усе працює. Потім ти вмикаєш RLS і для user_roles — бо ця таблиця чутлива —
і кожен запит до documents починає падати з 500. Ніщо зі змін у documents цього
не пояснює.
Причина — один факт про RLS: політика це не фільтр, що спрацьовує один раз згори. Вона
перевіряється на кожному читанні таблиці — включно з тим читанням, яке політика робить
усередині себе. Перемкни RLS на user_roles нижче, щоб відтворити момент, коли все зламалось.
SELECT * FROM documents
у documents увімкнено RLS; щоб перевірити «адмін», його політика читає user_roles.
- політика documents працює → щоб перевірити адміна, читає user_roles
- RLS на user_roles вимкнено → читання не перевіряється політикою → воно відповідає
- ✓ політика documents розв'язується → рядок повертається
Ось що працювало в твоїх тестах: RLS на user_roles був вимкнений, тож внутрішнє читання не перевірялося — ланцюг тут і закінчився. (Вимк ≠ deny-all: коли RLS вимкнено, немає політики, яку треба застосовувати.)
Ось що насправді робить база. Розгортай вимогу рівень за рівнем і дивись, що довелося б прочитати, щоб задовольнити політику. RLS виконується на кожному доступі до таблиці — включно з таблицями, які твоя політика читає всередині свого підзапиту.
create policy doc_access on documents for select using (owner_id = auth.uid()or exists (select 1 from user_roles urwhere ur.user_id = auth.uid() and ur.role = 'admin'));
create policy ur_self on user_roles for select using (exists (select 1 from user_roles where user_id = auth.uid())← читає user_roles, що запускає цю ж політику);
Політика на 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.) Ті самі рядки, той самий результат — зовсім інший
план. Перемкни предикат і потягни кількість рядків: