Supabase RBAC: Управление ролями через Custom Claims
Database / Security

Supabase RBAC: Управление ролями через Custom Claims

Н
Никита
06 дек. 2025 г.

1. Архитектура решения

Мы не будем доверять фронтенду. Схема такая:

  1. В таблице public.profiles есть колонка role.

  1. Когда админ меняет роль пользователю в этой таблице, срабатывает Postgres Trigger.
  1. Триггер копирует эту роль в скрытую системную таблицу auth.users (поле raw_app_meta_data).
  1. При следующем обновлении сессии (или логине) эта роль попадает в JWT токен.
  1. RLS политики читают роль прямо из токена.

2. Реализация (SQL)

Вам понадобится выполнить этот SQL в Dashboard Supabase (SQL Editor).

Шаг 1: Создаем Enum и таблицу

Сначала определим роли жестко, чтобы не было опечаток.

SQL
-- Создаем тип ролей
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.

SQL
-- Функция для синхронизации роли в метаданные юзера
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 Политики

Теперь самое вкусное. Мы пишем правила доступа, используя данные из токена.

Функция-хелпер для чистоты кода (опционально, но рекомендуется):

SQL
create or replace function auth.user_role() 
returns text as $$
  select (auth.jwt() ->> 'role');
$$ language sql stable;

Сценарий 1: Админ видит ВСЁ

SQL
create policy "Admins can do everything"
on public.some_secret_table
for all
using ( auth.user_role() = 'admin' );

Сценарий 2: Менеджер видит данные своего отдела (или региона)
Предположим, у менеджера в app_metadata есть еще и department_id.

SQL
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: Юзер видит только своё

SQL
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:

TYPESCRIPT
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:

TYPESCRIPT
'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 дает вам:

  1. Скорость: Проверка прав происходит в памяти Postgres (CPU bound), а не через чтение диска (IO bound).

  1. Изоляция: Данные защищены физически на уровне движка БД. Даже если ваш Next.js взломают, данные не утекут (если ключи сервисные не сольете).
  1. DX: Легко читать код. auth.user_role() = 'admin' читается как английский текст.

Нужен похожий проект?

Свяжитесь с нами для бесплатной консультации.

Обсудить задачу