Перейти до вмісту

Основи безпеки

Помилка Supabase "violates foreign key constraint" під час вставки

Supabase "insert or update on table violates foreign key constraint": ваш рядок посилається на рядок, якого немає. Який, чому і як це виправити.

Vlad Tkachenko11 хв читання
Вулиця з пронумерованими будинками, одна ділянка порожня й обведена бурштиновим, а попереду лист на відсутній номер.

Коротко

  • Supabase "violates foreign key constraint" під час вставки (код Postgres 23503, HTTP 409 у вашому застосунку) означає, що збережений рядок називає рядок іншої таблиці, якого не існує. Нічого не збережено. Повідомлення називає вашу таблицю й правило, а рядок деталей називає таблицю, у якій шукав Postgres.
  • Відсутній рядок так і не зберегли, зберегли другим, видалили тим часом або він живе в іншому проєкті. Коли рядок деталей каже "users", майже завжди мається на увазі auth.users, де Supabase Auth тримає всіх зареєстрованих людей.
  • Перевірка читає кожен рядок іншої таблиці, хоч би що казали ваші правила Row Level Security. Тож вона приймає й рядок, що вказує на чужий запис, і ваше правило вставки має перевіряти, кому цей запис належить.

Ви натискаєте Зберегти на новому профілі у своєму застосунку, і нічого не змінюється. Сторінка не показує жодного повідомлення. У консолі браузера воно є:

{
  "code": "23503",
  "details": "Key is not present in table \"users\".",
  "hint": null,
  "message": "insert or update on table \"profiles\" violates foreign key constraint \"profiles_id_fkey\""
}

Supabase "violates foreign key constraint" означає, що рядок, який ви намагалися зберегти, називає рядок іншої таблиці, а такого рядка не існує. Тут профіль ніс id, що мав належати комусь, хто зареєструвався, і Postgres не знайшов нікого з таким id. Нічого не збережено.

Найшвидше повідомлення зникне, якщо видалити правило. Після цього збереження проходять, а рядки, які вони створюють, вказують у порожнечу: позиція замовлення без замовлення, профіль без жодної людини за ним. Спершу зʼясуйте, якого рядка бракує і чому. Він зник одним із небагатьох способів.

Що означає "violates foreign key constraint" у Supabase?

Це означає, що один стовпець вашого рядка має збігатися з рядком іншої таблиці, значення, яке ви надіслали, не збігається з жодним, і Postgres відхилив увесь рядок. Документація Postgres про обмеження визначає це правило одним реченням: зовнішній ключ "specifies that the values in a column (or a group of columns) must match the values appearing in some row of another table."

Уявіть іншу таблицю як вулицю, а кожен її рядок як будинок із номером. Ваш новий рядок це лист, а стовпець із зовнішнім ключем це номер будинку, написаний на ньому. Postgres працює як сортувальний центр. Перш ніж прийняти лист, він шукає номер на вулиці, і лист до будинку, якого немає, одразу повертається. Повідомлення каже, який лист повернувся. Рядок деталей каже, на якій вулиці шукали.

ПолеПрикладЩо воно вам каже
code23503Код Postgres для цієї помилки, foreign_key_violation у його списку кодів
messageinsert or update on table "profiles" violates foreign key constraint "profiles_id_fkey"Таблиця, у яку ви писали, потім назва правила
detailsKey (id)=(…) is not present in table "users". або те саме без значенняСтовпець, значення, яке ви надіслали, і таблиця, у якій шукав Postgres. Наступний розділ пояснює, коли значення пропускається
hintnullДля цієї помилки нічого додати
HTTP-статус409 ConflictPostgREST, частина Supabase, що перетворює виклики supabase.from(...) на SQL, у своєму коді зіставляє 23503 зі статусом 409

Формулювання походить із файлу Postgres, який виконує ці перевірки. Update теж її викликає, коли змінює стовпець на номер без будинку, тому повідомлення починається з "insert or update".

Повідомлення, що починається з update or delete on table, це те саме правило, побачене з боку вулиці: ви намагалися знести будинок, на який досі вказують листи. Воно виникає під час видалення, і стаття про видалення користувачів його розбирає.

Який рядок шукає Postgres?

Той, що в рядку деталей, у таблиці, яку цей рядок називає. Дві речі роблять його складнішим для читання, ніж здається.

Іноді значення пропущене. Postgres пише Key (id)=(…) лише тоді, коли той, хто викликає, може читати цей стовпець вашої таблиці. Коли на таблиці, у яку ви писали, увімкнено Row Level Security, його код пропускає значення з коментарем "we don't return any specifics to avoid leaking data", і ви отримуєте Key is not present in table "users". Саме це бачить запит із браузера до захищеної таблиці. Значення те, що надіслав ваш застосунок, тож відкрийте інструменти розробника в браузері, знайдіть на вкладці Мережа запит до /rest/v1/profiles і прочитайте значення в payload.

Якщо ваша консоль показує значення для запиту, який ваш застосунок зробив із публічним ключем, Row Level Security на таблиці, у яку ви писали, вимкнено. Таблиця з вимкненими правилами віддає свої рядки незнайомцю так само охоче, як і вам, а увімкнення правил на кожній таблиці показує перемикач і перше правило. Наше безкоштовне сканування читає ваш живий застосунок із публічним ключем, який отримує кожен відвідувач, і перелічує таблиці, що йому відповідають. Обліковий запис не потрібен.

Назва таблиці без схеми. Postgres пише users, і в проєкті Supabase стовпець, що вказує на людей, майже завжди вказує на auth.users, таблицю, де Supabase Auth тримає всіх зареєстрованих людей. Її немає серед ваших публічних таблиць у Table Editor, тому повідомлення читається так, ніби називає таблицю, якої ви ніколи не створювали.

Щоб побачити, куди саме вказує правило, виконайте це в SQL Editor із назвою правила з повідомлення:

select conrelid::regclass as on_table,
       pg_get_constraintdef(oid) as rule
from pg_constraint
where conname = 'profiles_id_fkey';

Відповідь назве таблицю, на якій стоїть правило, і що це правило каже, наприклад FOREIGN KEY (id) REFERENCES auth.users(id) ON DELETE CASCADE: ваш стовпець, потім таблиця й стовпець, з якими він має збігатися. Потім знайдіть значення:

select id, email, created_at from auth.users where id = '…';

Якщо не повернеться жодного рядка, людини, яку називає ваш профіль, у цьому проєкті не існує.

Звідки береться рядок, що вказує в порожнечу?

З небагатьох місць, і таблиця в рядку деталей підкаже, куди дивитися спершу.

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

const { data: order, error } = await supabase
  .from('orders')
  .insert({ user_id: user.id })
  .select('id')
  .single()

if (error) throw error

const { error: linesError } = await supabase
  .from('order_items')
  .insert(cart.map((item) => ({ ...item, order_id: order.id })))

.select() після insert читає новий рядок назад, тож людині потрібне правило, яке дозволяє їй читати власні замовлення. Документація Postgres про policies каже, що новий рядок, який не проходить правил читання, змушує збереження завершитися помилкою, і в Supabase ця помилка звучить як new row violates row-level security policy. Два збереження це окремі запити, тож якщо позиції не збереглися, замовлення вже збережене: надішліть позиції ще раз або видаліть замовлення.

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

Id прийшов звідкись іще. Кожен проєкт Supabase має власну auth.users. Id, скопійований із вашого тестового проєкту, id, який конструктор зі ШІ залишив у коді з попереднього перегляду, або рядки, скопійовані з одного проєкту в інший, усі називають людей, яких живий проєкт ніколи не бачив. Коли копіюєте дані між проєктами, копіюйте рядки, на які вказують, раніше за рядки, що на них вказують. Людей за стовпцем, що вказує на auth.users, так не скопіювати: вони реєструються в новому проєкті й отримують при цьому нові id. Профіль, чий id має бути id людини, ламається й тоді, коли застосунок заповнює його свіжим випадковим значенням, наприклад crypto.randomUUID(). Цей номер не належить жодному будинку. Беріть user.id з поточної сесії.

Рядок видалили тим часом. Товар, який прибрали з вашого магазину, поки він лежав у чиємусь кошику, або обліковий запис, видалений в іншій вкладці. Збереження, що йде слідом, вказує на будинок, який стояв, коли сторінка завантажилася. Виправлення у вашому застосунку: перевіряйте error.code === '23503' і кажіть людині, що змінилося, наприклад "Цей товар більше не доступний."

Тригер, що спрацьовує зарано. Тригер на auth.users, який копіює кожну нову людину в profiles, має спрацьовувати після того, як людину збережено. Документація Postgres про тригери каже, що зміна, яка запускає рядковий тригер BEFORE, "is naturally not visible to SQL commands executed in a row-level BEFORE trigger, because it hasn't happened yet". Тож профіль, записаний із тригера before insert, вказує на людину, якої ще не існує. Кожна реєстрація падає, і Supabase повідомляє про це як "Database error saving new user". Шаблон самого Supabase, який та стаття наводить повністю, використовує after insert.

Чому порожнє значення проходить?

Бо зовнішній ключ перевіряє лише ті значення, що є. Документація Postgres про обмеження каже так: "Normally, a referencing row need not satisfy the foreign key constraint if any of its referencing columns are null." Null це слово Postgres для відсутності будь-якого значення. Лист без номера будинку ніхто не шукає. Він потрапляє в мішок і не належить жодному будинку.

Тож позиція замовлення, збережена з порожнім order_id, приймається і не належить жодному замовленню. Ваш застосунок натрапить на неї пізніше, як на позицію, що не зʼявляється ні в чиєму кошику. Та сама сторінка називає виправлення: "declare the referencing column(s) as NOT NULL".

alter table public.order_items alter column order_id set not null;

Після цього порожнє значення теж відхиляється, з повідомленням, яке прямо називає стовпець, null value in column violates not-null constraint. Команда не спрацює, поки вже є рядки з порожнім order_id, тож спершу перелічіть їх через select * from public.order_items where order_id is null.

Чи може правило ховати рядок, який він шукає?

Ні. Postgres перевіряє зовнішній ключ по кожному рядку іншої таблиці, хоч би що казали ваші правила Row Level Security. Його документація про безпеку на рівні рядків каже про це прямо: "Referential integrity checks, such as unique or primary key constraints and foreign key references, always bypass row security to ensure that data integrity is maintained." У коді, що виконує перевірку, пошук перемикається на власника іншої таблиці, із прапорцем, який не дає застосуватися навіть примусовому правилу.

Тож сортувальний центр працює з повним планом вулиці. Ваш застосунок бачить вулицю крізь свої правила, які, можливо, показують йому лише ті будинки, куди йому можна, а перевірка ніколи не користується цим поглядом. 23503 означає, що будинку немає ні для кого. Якщо рядок існує, а ваш застосунок його не бачить, збереження проходить.

Ваш застосунок читає рядки, які дозволяють його правила. Перевірка зовнішнього ключа читає всі рядки, тож рядок, що вказує на будинок, якого ваш застосунок не бачить, приймається.

Чи може хтось причепити рядок до чужого запису?

Так, якщо ваше правило вставки не перевіряє, кому цей запис належить. Перевірка того, що будинок існує, ігнорує ваші правила, тож вона каже так і будинку, який належить комусь іншому.

Візьміть order_items із таким правилом вставки, яке виглядає безпечним:

create policy "Add your own order lines"
on public.order_items for insert
to authenticated
with check (auth.uid() = user_id);

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

Замініть його правилом, яке дивиться на замовлення:

create policy "Add lines to your own orders"
on public.order_items for insert
to authenticated
with check (
  exists (
    select 1 from public.orders
    where orders.id = order_items.order_id
      and orders.user_id = auth.uid()
  )
);

Видаліть перше правило, коли додаєте це. Postgres поєднує правила одного виду через "OR", як сказано в його документації про policies, тож старе й далі пропускало б позицію самостійно.

Перевірка ще й відповідає на запитання кожному, кому дозволено вставляти. Речення після процитованого вище, у документації про безпеку на рівні рядків, застерігає: "Care must be taken when developing schemas and row level policies to avoid 'covert channel' leaks of information through such referential integrity checks." Збереження, що падає з 23503, каже, що будинку немає. Збереження, що вдається, каже, що він є, на вулиці, яку людина не може прочитати. Id користувачів у Supabase це довгі випадкові значення, яких ніхто не вгадає. Коли стовпець вказує на щось, що можна вгадати, наприклад на адресу електронної пошти чи id, що рахується від 1, людина може пробувати значення по одному й дізнатися, які існують.

Чи варто прибрати зовнішній ключ?

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

Правило також вирішує, що стається з цими рядками, коли видаляють рядок, на який вони вказують. З on delete cascade на правилі від profiles до auth.users видалення людини видаляє і її профіль. Без правила профіль лишається з усіма особистими даними, які в ньому є. Стаття про видалення користувачів пояснює, як вирішити, що зберігає кожна таблиця.

Якщо старі рядки вже вказують у порожнечу, перелічіть їх, перш ніж повертати правило чи посилювати його, бо Postgres відмовляється додавати зовнішній ключ, поки хоч один рядок його порушує:

select i.*
from public.order_items i
left join public.orders o on o.id = i.order_id
where i.order_id is not null
  and o.id is null;

Для кожного вирішіть, чи повернеться до нього замовлення, чи він піде. Видалення не скасувати без копії бази даних із часу до нього.

Перш ніж лагодити рядки

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

Reeve Care зберігає копію вашої бази даних Supabase:

  • зашифрована копія за розкладом, у власному сховищі Reeve в ЄС, поза вашим обліковим записом Supabase
  • кожен рядок таким, яким він був до того, як прибирання видалило не ті
  • кожна копія перевірена, перш ніж її зараховують, підрахунком рядків у кожній таблиці
  • ваші завантажені файли теж, щойно ви підключите облікові дані Storage
  • відновлення одним кліком, перед яким береться страхувальна копія поточного стану
  • усе, що робить Monitor

Reeve Monitor і далі перевіряє ззовні:

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

Що зробити зараз

Що робити

  • Прочитайте таблицю в рядку деталей. У Supabase users там майже завжди означає auth.users.
  • Знайдіть значення: у рядку деталей або в запиті, який надіслав ваш застосунок.
  • Спершу збережіть рядок, на який вказують, візьміть його id із відповіді й читайте кожну помилку перед наступним збереженням.
  • Дайте not null стовпцю, який мусить кудись вказувати, щоб порожнє значення теж відхилялося.
  • Нехай кожне правило вставки перевіряє, кому належить рядок, на який вказує новий.
  • Залиште зовнішній ключ і перелічіть рядки, що вказують у порожнечу, перш ніж видаляти бодай один.

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

Поширені запитання

Що означає insert or update on table violates foreign key constraint?

Рядок, який ви намагалися зберегти, вказує на рядок іншої таблиці, якого не існує, і Postgres його відхилив. Нічого не записано. Повідомлення називає таблицю, у яку ви писали, і правило, а рядок деталей називає таблицю, у якій шукав Postgres. Код 23503, і із застосунку на Supabase він приходить як HTTP 409.

Чому помилка згадує таблицю "users", хоча в мене такої немає?

Бо йдеться майже напевно про auth.users, таблицю, де Supabase Auth тримає всіх зареєстрованих людей. Postgres пише назву таблиці без її схеми. Ваш стовпець вказує на людину, а id у ньому не належить нікому в цьому проєкті.

Чи може Row Level Security спричинити помилку зовнішнього ключа?

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

Чому рядок деталей каже "Key is not present" без значення?

Postgres пропускає значення, коли на таблиці, у яку ви писали, увімкнено Row Level Security, або коли той, хто викликає, не може читати цей стовпець. Значення те, що надіслав ваш застосунок, тож прочитайте його в запиті, на вкладці Мережа в інструментах розробника браузера.

Чи прибрати зовнішній ключ, щоб помилка зникла?

Ні. Збереження проходять, а рядки вказують у порожнечу: позиція замовлення без замовлення, профіль без людини за ним. Правило також вирішує, що стається з цими рядками, коли користувача видаляють. Зʼясуйте, якого рядка бракує, і збережіть його першим, або виправте id.

Автор

Vlad Tkachenko

Засновник Reeve

Я щодня дивлюся на застосунки, зібрані в Lovable, Bolt, v0, Cursor і Replit, і на короткий список помилок, які трапляються в них знову і знову.

Більше про автора

Читати далі

Усі статті

Зробіть резервну копію бази Supabase перед наступною зміною

Reeve за розкладом зберігає зашифровану копію і відновить її за вас, якщо щось зламається.

Резервні копії Supabase

Автоматична зовнішня перевірка, а не повний аудит. Відсутність знахідок не є гарантією безпеки.