Стек: prisma-generator-express + prisma-guard: генерация CRUD-роутера, валидация ввода, ограничение формы запроса и изоляция тенантов. Подход я для себя называю shape-as-boundary: форма запроса становится исполняемой границей доступа.
В примерах ниже — сайт аренды/продажи недвижимости. Реальный проект был другим, кадровой платформой, но я перевожу примеры на недвижимость, чтобы не добавлять лишний контекст.
Постановка
Типичный CRUD-бэкенд накапливает три категории багов быстрее, чем кажется на старте:
-
Невалидированный ввод попадает в Prisma.
prisma.listing.create({ data: req.body })— и клиент фактически управляет всем содержимым запроса, включаяagencyId,verifiedAt,commissionRate. -
Слишком широкая форма запроса. Клиент через
includeдоходит доagent.passwordHashили черезwhereфильтрует поinternalNotes. -
Утечки между агентствами.
findFirst({ where: { id:req.params.id} })без условия по агентству — и агент видит объявления чужой компании. В терминах SaaS это утечка между тенантами; в терминах предметной области — между агентствами. Дальше я использую «агентство» для домена и «тенант» для архитектуры.
GraphQL сам по себе не задаёт границу доступа к данным. Apollo+TypeGraphQL дают типы и удобный DSL, но границы доступа всё равно остаются в резолверах и правилах авторизации: проверки переписываются в каждом, фильтр по тенанту легко забыть, чувствительные поля модели легко добавить в селекцию случайно. У меня вдобавок резолверы разрослись до состояния, когда дашборд — это одна крупная вложенная GraphQL-операция с восемью вложенными take/orderBy/where, и нет ощущения границы между «что клиент может» и «что сервер согласен отдать».
Основная идея
Форма Prisma-аргументов (shape) — декларация того, какие поля клиент может передать, какие поля сервер принудительно добавляет и какую проекцию ответа разрешает. Одна структура одновременно:
-
валидирует тело запроса (через сгенерированные из Prisma Zod-схемы),
-
ограничивает форму Prisma-запроса (списком разрешённых полей в
where/select/include/orderBy), -
задаёт проекцию по умолчанию, если клиент
select/includeне прислал, -
подставляет принудительные значения (тенант, владелец, флаги видимости).
Пример простейшего поиска объявлений:
{ where: { city: { equals: true }, type: { in: true }, priceMin: { gte: true }, priceMax: { lte: true }, isPublished: { equals: force(true) }, deletedAt: { equals: force(null) }, }, orderBy: { createdAt: true, priceMin: true }, take: { default: 20, max: 50 }, select: listingPublicSelect,}
Смысл такой: клиент может фильтровать по городу, типу, диапазону цены и сортировать по дате/цене с лимитом до 50. Сервер всегда добавляет isPublished = true и deletedAt IS NULL — независимо от того, что прислал клиент. Если клиент попробует прислать where: { ownerEmail: { contains: 'gmail' } } — отказ, это поле не разрешено формой.
true означает «клиент управляет». Литерал (null, число, строка) или force(...) — серверное принуждение. Тонкость: если значение должно быть true как литерал (поле isPublished всегда true), пишется force(true), иначе DSL посчитает это разрешением клиенту. То есть equals: true — клиент управляет, equals: force(true) — сервер принуждает. К этой неоднозначности ещё вернусь.
Pathname-as-variant
У одной модели разные страницы требуют разные варианты доступа: на странице поиска одна форма фильтрации, в дашборде агентства другая, в публичном API третья. Можно завести разные эндпоинты — но это больше URL и больше мест, где забыть про правило доступа.
prisma-guard выбирает shape по строковому ключу — caller. prisma-generator-express извлекает caller через resolveVariant(req) или, по умолчанию, из заголовка x-api-variant. Клиент автоматически проставляет туда текущий путь:
headers: { 'x-api-variant': window.location.pathname, ...options.headers }
И тогда таблица форм выглядит так:
findMany: { shape: { "/listings/search": { /* поиск с фильтрами */ }, "/agency/dashboard": { /* активные объявления агентства */ }, "/agent/dashboard": { /* мои объявления */ }, "public": { /* публичная выдача */ }, },}
Один и тот же URL /api/v1/listing для findMany — но shape выбирается по тому, какая страница сделала запрос.
Важно: x-api-variant не является границей авторизации. Это удобный ключ выбора варианта, не источник прав. Заголовок задаётся клиентом, и его можно подделать. Если разные варианты дают разный уровень доступа (например, /admin/listings/search показывает больше полей чем /listings/search), caller обязательно должен вычисляться на сервере — через resolveVariant(req) из auth/context/role — а не браться напрямую из клиентского заголовка. Заголовок допустим только тогда, когда каждая форма, которую можно выбрать этим заголовком, безопасна для этого пользователя даже при подмене варианта.
В моём проекте варианты различают структуру ответа, но не уровень доступа: и /agency/dashboard, и /listings/search доступны любому авторизованному агенту. Поэтому x-api-variant из заголовка здесь подходит. Когда я добавлю /admin/listings/search — будет resolveVariant, читающий роль из JWT.
Принудительные значения и контекст тенанта
Контекст попадает в shape через AsyncLocalStorage: Express middleware заполняет хранилище после JWT-декода, а prisma-guard читает его при вызове .guard().
store.run( { userId: ctx.auth?.id, agencyId: ctx.activeAgency?.id }, () => next(),);
Это единственное место, где значения попадают в ALS. Дальше — prisma-guard лениво читает их в контекст-функции при каждом вызове .guard():
prisma.$extends( guard.extension(() => ({ userId: store.getStore()?.userId, agencyId: store.getStore()?.agencyId, })))
Здесь есть неочевидный момент. На первый взгляд может показаться, что хранилище ещё пустое в момент инициализации Prisma — но лямбда не вызывается при $extends. Она сохраняется и вызывается каждый раз, когда вызывается .guard() — то есть глубоко внутри обработчика запроса, после того как middleware заполнил ALS. Тело лямбды читается тогда, когда оно нужно.
Дальше shape-функция получает контекст как параметр:
findFirst: { shape: { "/agent/dashboard": (ctx) => ({ select: agentDashboardSelect, where: { id: { equals: force(ctx.userId) }, }, }), },},
Это RPC-эквивалент GraphQL-овского findMe: «найти пользователя, где id всегда равен моему ctx.userId». Клиент даже where не отправляет — shape-функция всё подставляет сама. На уровне HTTP это выглядит так:
GET /api/v1/user/firstx-api-variant: /agent/dashboardAuthorization: Bearer ...
Пустое тело. Сервер сам знает, кого ищет.
То же для агентства:
"/agency/dashboard": (ctx) => ({ select: agencyDashboardSelect, where: { id: { equals: force(ctx.agencyId) }, },}),
Дашборд агентства не может прочитать чужое агентство, даже если клиент прислал некорректный id. Сервер берёт agencyId из своего контекста, и пользователь не может переопределить его через этот маршрут.
В продакшене я бы не писал force(ctx.userId) без проверки контекста. Если ctx.userId или ctx.agencyId отсутствует — это ошибка авторизации или конфигурации, и shape-функция должна завершаться понятной ошибкой до вызова Prisma, а не полагаться на поведение undefined. Простейший вариант — проверка прямо в shape-функции, до возврата объекта:
"/agent/dashboard": (ctx) => { if (!ctx.userId) throw new Error("Missing userId in context"); return { select: agentDashboardSelect, where: { id: { equals: force(ctx.userId) } } };},
Вложенные селекты: дашборд одним запросом
GraphQL-вариант дашборда агентства у меня был такой: findAgency → listings(orderBy, where, take) → applications(...) → tenant(...), плюс пять-семь параллельных вложенных полей с собственными фильтрами. Это работало, но это была крупная вложенная операция с разветвлённой селекцией, и граница безопасности в ней не была явной.
В новом подходе это пишется как одна shape-конфигурация с вложенными select-блоками:
"/agency/dashboard": (ctx) => ({ where: { id: { equals: force(ctx.agencyId) } }, select: { id: true, name: true, _count: { select: { listings: true, agents: true } }, listings: { orderBy: { createdAt: true }, take: { default: 10, max: 10 }, where: { deletedAt: { equals: force(null) } }, select: { id: true, title: true, priceMin: true, city: true, _count: { select: { inquiries: true, views: true } }, inquiries: { orderBy: { createdAt: true }, take: { default: 5, max: 5 }, where: { deletedAt: { equals: force(null) } }, select: { id: true, createdAt: true, message: true, tenant: { select: { id: true, name: true } }, }, }, }, }, pendingInvitations: { take: { default: 5, max: 5 }, where: { ignored: { equals: force(null) } }, select: { /* ... */ }, }, },}),
Для вложенных to-many связей take, orderBy и принудительный where работают внутри shape. Клиент шлёт пустое тело — shape применяется как селекция по умолчанию, лимиты применяются, deletedAt: null подставляется на каждом уровне. Один HTTP-запрос, одна Prisma-операция. На уровне SQL Prisma может разбить это на несколько запросов в зависимости от структуры связей — это её внутренняя деталь.
Важная грань: вложенные чтения и автоматический tenant scope — не одно и то же. Принудительный where внутри shape (то, что выше) и автоматический tenant scope, который prisma-guard добавляет через @scope-root — разные механизмы. Автоматический scope работает только на top-level операцию; вложенные чтения через include/select им не фильтруются. Если связь сама по себе чувствительна по тенанту, защиту нужно либо явно прописывать в shape (where: { agencyId: { equals: force(ctx.agencyId) } } внутри вложенного select), либо закрывать на уровне БД: RLS, композитные ключи/ограничения, схемные инварианты. Не полагайтесь на то, что scope «протечёт вниз» — он этого не делает.
На моём дашборде эта схема заменила GraphQL-запрос на ~150 строк одной структурой на ~80, где вся структура — это декларация того, что нужно вернуть и где это безопасно. Резолверов нет вообще.
История миграции
Я не делал миграцию одним коммитом. Порядок был такой:
-
Сначала инфраструктура контекста — ALS. Без
store.runв middleware shape получаетundefinedвместо контекста. Это видно по логам:console.log(store.getStore())в guard-расширении пишетundefined, и любаяforce(ctx.userId)получаетundefined. Это должен быть fail-fast сценарий: еслиuserIdилиagencyIdотсутствует, лучше падать до вызова Prisma, а не надеяться на поведениеundefined. Заполнение хранилища — обязательный нулевой шаг. -
Сначала одно сложное чтение. Я начал с дашборда пользователя. Сложный, потому что много вложенных селектов с разными фильтрами и
take— это лучший стресс-тест для DSL. И второй довод: у запроса один корневой объект (findMe→user.findFirstс принуждением поuserId). -
GraphQL остаётся параллельно. Старая GraphQL-операция продолжала работать, пока новая RPC-страница не подтверждена. Никакой резкой замены. На клиенте
usePrivateApolloменялся наuseFetchQueryпоштучно — страница за страницей. -
Дашборд агентства вторым. Сложнее, потому что добавляется агентство как корневой объект (
agency.findFirstсforce(ctx.agencyId)), плюс ещё уровень вложенности. -
Удалить GraphQL-код. Только когда обе страницы работают.
Что я не делал и рад этому: я не пытался переписать одним движением страницы со сложным поиском. Они оставались на старом стеке, потому что мигрировать их — это не «одна shape-конфигурация с вложенными селектами», а «список разрешённых из десятков фильтров, индексы, краевые случаи». Миграция работает, когда понятно, какой кусок легче, и берёшь его первым.
Что оказалось неудобным на практике
true перегружено. В shape where: { isPublished: { equals: true } } означает «клиент может фильтровать по isPublished». А where: { isPublished: { equals: force(true) } } означает «всегда true». Во время миграции я однажды написал deletedAt: { equals: true } думая «всегда удалённые», на деле «клиент может фильтровать по deletedAt и присылать любое значение». Помогает только привычка: «увидел true — это разрешение, а не значение».
Пустые операторы. Когда клиент строит where: { city: { equals: maybeCity } } и maybeCity === undefined, JSON.stringify дропает equals. Сервер видит city: {} и отвергает: «At least one operator required». Хороший отказ по умолчанию, но неудобный — нужен приём на клиенте «убирай ключ, если значение пустое»:
const where = { type: { in: types } };if (city) where.city = { equals: city };
Я наступал на это дважды при миграции. Если у вас форма фильтрации с динамическими полями — закладывайте это сразу.
Принудительный where на связи «один-к-одному» не работает. Это ограничение Prisma, а не библиотеки, но в архитектуре оно даёт о себе знать. Можно принуждать where на связи «один-ко-многим» (listings: { where: { isPublished: { equals: force(true) } } }), но не на «один-к-одному» (agency: { where: { isVerified: { equals: force(true) } } }). Для связи «один-к-одному» защита должна быть не «where на связи», а одно из трёх: не включать связь в селект; возвращать только безопасные скалярные поля через вложенный select; гарантировать границу на уровне схемы/БД. Для арендной недвижимости это проявится на связи listing → owner: если хочется показать имя владельца, но никогда email — селекция скалярных полей, не принуждение.
Дублирование структуры на клиенте. TypeScript не знает, какой shape за каким pathname скрывается. Клиент строит where: { city: { equals: city } } и надеется, что shape это разрешает. Если нет — 400 в продакшене. Это, кстати, правильно — граница безопасности должна это отвергать. Я какое-то время рассматривал идею сгенерировать типы из shape, но это значит вытаскивать границу безопасности в этап компиляции там, где она уже работает в рантайме. Лучше получить 400 и поправить shape или клиентский запрос, чем строить дополнительную инфраструктуру кодогенерации ради сокращения цикла итерации на 30 секунд. Для маленькой команды этот аргумент работает. Для большой команды, где 400 в продакшене превращается в координационную проблему, уже стоит подумать о генерации типов из shape.
Не для ad-hoc запросов. Если клиент хочет «возьми объявление, его агента, его агентство, последние 5 откликов, средний рейтинг арендатора по комментариям откликов» — и каждый день этот запрос новый — RPC+shape скорее не лучший выбор. Стабильный поиск с известным набором фильтров — да. Ad-hoc конструктор запросов с десятками меняющихся условий — скорее нет. Подход с shape удобен, когда структура ответа страницы стабильна.
Когда я бы выбрал этот стек, когда нет
Выбрал бы:
-
CRUD-тяжёлая многотенантная SaaS (агентство недвижимости, кадровая платформа, биллинг)
-
Дашборды и страницы со стабильной структурой ответа
-
Маленькая или средняя команда (1–10 разработчиков), где таблица форм помещается в голове и каждое поле имеет смысл
-
Когда GraphQL Federation/Subgraph избыточен
Не выбрал бы:
-
Аналитика, BI, конструктор запросов для пользователя
-
Команды на 50+ разработчиков с десятками автономных моделей, где общая таблица форм станет узким местом ревью
-
Когда основной клиент — внешние интеграции и процесс строится contract-first: сначала публичный OpenAPI/GraphQL-контракт, потом реализация
Итоги
-
Декларация = граница. Shape — это не декоративная валидация поверх логики, а часть границы доступа к данным.
-
Pathname-as-variant — ключ выбора варианта, не авторизация. Авторизация всё равно должна быть в
resolveVariantили в обработчике авторизации. -
ALS — нормально, если заполнение в правильном middleware. Это не магия, это
store.run(value, callback)обёрнутый вокругnext(). -
Сложные дашборды через вложенные селекты — да. Ad-hoc query builder через тот же механизм — нет.
Ограничения, которые стоит знать
-
findUniqueиfindUniqueOrThrowнельзя безопасно ограничить tenant scope через Prisma extension. Для scoped моделей лучше держатьfindUniqueMode = "reject"и использоватьfindFirstс принудительным tenant/user условием. -
Автоматический tenant scope работает только на top-level операцию. Вложенные чтения через
include/selectим не фильтруются. -
Вложенные записи связей (relation writes в data) не перехватываются scope-расширением. Если используете — закрывайте границы на уровне БД или явно в shape.
-
Raw SQL (
$queryRaw,$executeRaw) обходит этот уровень защиты, поэтому такие запросы нужно проверять отдельно. -
x-api-variant— не источник прав. Подделывается клиентом.
Практические заметки
Как заполнить ALS. В middleware после любого JWT/auth:
export function createRestContext(prisma: any): RequestHandler { return async (req, res, next) => { const ctx = await getContext({ req, res } as any, prisma); req.context = ctx; store.run( { userId: ctx.auth?.id, agencyId: ctx.activeAgency?.id }, () => next(), ); };}
store.run обязательно оборачивает next(), иначе область видимости ALS закроется до того, как обработчик маршрута до неё доберётся.
URL-схема роутера.
-
findMany→GET /{model}/; для длинных запросов есть POST-альтернативаPOST /{model}/read(потому чтоPOST /{model}/занят под create). -
findFirst→GET /{model}/firstилиPOST /{model}/first. -
findManyPaginated→GET /{model}/paginatedилиPOST /{model}/paginated.
Для длинных дашбордных запросов с вложенными селектами POST-альтернатива удобнее, чем передавать всё через query string.
Дашборд агента, шаги сервера.
GET /api/v1/user/firstx-api-variant: /agent/dashboard
-
JWT декодируется →
userIdпопадает в ALS -
Express маршрутизирует на сгенерированный
userRouter.findFirst -
prisma-guardсмотрит наx-api-variant: /agent/dashboard -
Находит shape, вызывает
(ctx) => ({...})с заполненнымctx.userId -
Принуждает
where: { id: { equals: ctx.userId } } -
Применяет вложенный select: профиль, сохранённые объявления, отклики, активность
-
Prisma собирает и выполняет запросы к БД
-
Ответ — структура, в точности соответствующая shape
Никаких резолверов. Никаких ручных проверок «а это точно мой userId». Граница описана в одном месте, она и есть безопасность.
Главный вывод: форма запроса должна быть не подсказкой для клиента, а исполняемой границей на сервере. Всё, что не описано в shape, не должно доходить до Prisma.
ссылка на оригинал статьи https://habr.com/ru/articles/1035418/