|
Professor Seleznov
|
Стек: 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/first x-api-variant: /agent/dashboard Authorization: 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/first x-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.-Источник
|