1. Архитектура решения
Мы не будем доверять фронтенду. Схема такая:
- В таблице
public.profilesесть колонкаrole.
- Когда админ меняет роль пользователю в этой таблице, срабатывает Postgres Trigger.
- Триггер копирует эту роль в скрытую системную таблицу
auth.users(полеraw_app_meta_data).
- При следующем обновлении сессии (или логине) эта роль попадает в JWT токен.
- RLS политики читают роль прямо из токена.
2. Реализация (SQL)
Вам понадобится выполнить этот SQL в Dashboard Supabase (SQL Editor).
Шаг 1: Создаем Enum и таблицу
Сначала определим роли жестко, чтобы не было опечаток.
-- Создаем тип ролей
create type app_role as enum ('admin', 'manager', 'user');
-- Добавляем колонку в профили (если таблицы нет - создайте)
alter table public.profiles
add column role app_role not null default 'user';
-- ВАЖНО: Только админ может менять эту колонку!
-- Обычный юзер не должен иметь права update на колонку role.Шаг 2: Магия (Функция и Триггер)
Нам нужна функция с правами security definer. Это значит, что она выполняется с правами создателя (суперюзера), даже если её вызвал обычный триггер. Это позволяет ей писать в защищенную схему auth.
-- Функция для синхронизации роли в метаданные юзера
create or replace function public.handle_role_update()
returns trigger as $$
begin
update auth.users
set raw_app_meta_data =
jsonb_set(coalesce(raw_app_meta_data, '{}'::jsonb), '{role}', to_jsonb(new.role))
where id = new.id;
return new;
end;
$$ language plpgsql security definer;
-- Вешаем триггер на обновление профиля
create trigger on_profile_role_updated
after update of role on public.profiles
for each row execute procedure public.handle_role_update();
-- И триггер на создание профиля (чтобы сразу прописать дефолт)
create trigger on_profile_created
after insert on public.profiles
for each row execute procedure public.handle_role_update();Шаг 3: Пишем "Железобетонные" RLS Политики
Теперь самое вкусное. Мы пишем правила доступа, используя данные из токена.
Функция-хелпер для чистоты кода (опционально, но рекомендуется):
create or replace function auth.user_role()
returns text as $$
select (auth.jwt() ->> 'role');
$$ language sql stable;Сценарий 1: Админ видит ВСЁ
create policy "Admins can do everything"
on public.some_secret_table
for all
using ( auth.user_role() = 'admin' );Сценарий 2: Менеджер видит данные своего отдела (или региона)
Предположим, у менеджера в app_metadata есть еще и department_id.
create policy "Managers see their department"
on public.reports
for select
using (
auth.user_role() = 'manager'
and
department_id = (auth.jwt() ->> 'department_id')::uuid
);Сценарий 3: Юзер видит только своё
create policy "Users see own data"
on public.profiles
for select
using (
-- Либо ты админ, либо это твой профиль
auth.user_role() = 'admin'
or
id = auth.uid()
);3. Next.js: Использование на клиенте и сервере
Теперь у вас в объекте user внутри сессии есть поле role. Вам не нужно делать запрос в базу, чтобы узнать, админ ли юзер.
Server Side (Helper):
Создадим утилиту, чтобы не писать парсинг каждый раз.src/lib/auth/role.ts:
import { createClient } from '@/lib/supabase/server'
export async function getUserRole() {
const supabase = createClient()
const { data: { session } } = await supabase.auth.getSession()
// Роль уже внутри токена! 0 запросов к БД.
const role = session?.user.app_metadata.role as 'admin' | 'manager' | 'user'
return role || 'guest'
}
export async function requireRole(requiredRole: string) {
const role = await getUserRole()
if (role !== requiredRole && role !== 'admin') {
throw new Error('Unauthorized') // Или редирект
}
}Использование в Server Action:
'use server'
import { requireRole } from '@/lib/auth/role'
export async function deleteUser(userId: string) {
// Проверка прав ДО выполнения логики
await requireRole('admin')
// ... логика удаления
}4. Подводные камни (Senior Experience)
Здесь можно выстрелить себе в ногу, если не знать нюансов JWT.
1. Проблема "Протухшего токена"
JWT токен выдается обычно на 1 час.
Ситуация: Вы повысили юзера до Админа в базе данных. Триггер сработал, метаданные обновились. НО! У юзера в браузере старый токен, где он все еще user. Он не получит доступ к админке еще 59 минут.
Решение:
На фронтенде (или в ответ на действие админа) нужно принудительно обновить сессию юзера.await supabase.auth.refreshSession()
Примечание: Если вы меняете роль другому юзеру, вы не можете обновить его сессию со своего компьютера. Ему придется перелогиниться или ждать истечения токена. В критичных системах (банковское ПО) используют проверку в БД на каждый чих, но для 99% SaaS задержка допустима.
2. Защита колонки role
Убедитесь, что у вас нет RLS политики, которая позволяет юзеру делать UPDATE своей строки в profiles без ограничений по колонкам.
Плохо: USING (id = auth.uid()) для UPDATE. Юзер пошлет запрос { role: 'admin' } и станет админом.
Хорошо: Ограничить обновляемые колонки на уровне API или сделать отдельную функцию для апдейта профиля, которая игнорирует поле role.
5. Итоговое мнение
Использование auth.jwt() ->> 'role' в RLS политиках — это единственный способ масштабировать Supabase. Если вы делаете Join таблиц в RLS — ваше приложение ляжет при нагрузке.
Подход через Custom Claims дает вам:
- Скорость: Проверка прав происходит в памяти Postgres (CPU bound), а не через чтение диска (IO bound).
- Изоляция: Данные защищены физически на уровне движка БД. Даже если ваш Next.js взломают, данные не утекут (если ключи сервисные не сольете).
- DX: Легко читать код.
auth.user_role() = 'admin'читается как английский текст.
Нужен похожий проект?
Свяжитесь с нами для бесплатной консультации.
