CRUD без боли: форма запроса как граница безопасности поверх Prisma

от автора

Стек: prisma-generator-express + prisma-guard: генерация CRUD-роутера, валидация ввода, ограничение формы запроса и изоляция тенантов. Подход я для себя называю shape-as-boundary: форма запроса становится исполняемой границей доступа.

В примерах ниже — сайт аренды/продажи недвижимости. Реальный проект был другим, кадровой платформой, но я перевожу примеры на недвижимость, чтобы не добавлять лишний контекст.

Постановка

Типичный CRUD-бэкенд накапливает три категории багов быстрее, чем кажется на старте:

  1. Невалидированный ввод попадает в Prisma. prisma.listing.create({ data: req.body }) — и клиент фактически управляет всем содержимым запроса, включая agencyId, verifiedAt, commissionRate.

  2. Слишком широкая форма запроса. Клиент через include доходит до agent.passwordHash или через where фильтрует по internalNotes.

  3. Утечки между агентствами. 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-вариант дашборда агентства у меня был такой: findAgencylistings(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, где вся структура — это декларация того, что нужно вернуть и где это безопасно. Резолверов нет вообще.

История миграции

Я не делал миграцию одним коммитом. Порядок был такой:

  1. Сначала инфраструктура контекста — ALS. Без store.run в middleware shape получает undefined вместо контекста. Это видно по логам: console.log(store.getStore()) в guard-расширении пишет undefined, и любая force(ctx.userId) получает undefined. Это должен быть fail-fast сценарий: если userId или agencyId отсутствует, лучше падать до вызова Prisma, а не надеяться на поведение undefined. Заполнение хранилища — обязательный нулевой шаг.

  2. Сначала одно сложное чтение. Я начал с дашборда пользователя. Сложный, потому что много вложенных селектов с разными фильтрами и take — это лучший стресс-тест для DSL. И второй довод: у запроса один корневой объект (findMeuser.findFirst с принуждением по userId).

  3. GraphQL остаётся параллельно. Старая GraphQL-операция продолжала работать, пока новая RPC-страница не подтверждена. Никакой резкой замены. На клиенте usePrivateApollo менялся на useFetchQuery поштучно — страница за страницей.

  4. Дашборд агентства вторым. Сложнее, потому что добавляется агентство как корневой объект (agency.findFirst с force(ctx.agencyId)), плюс ещё уровень вложенности.

  5. Удалить 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-контракт, потом реализация

Итоги

  1. Декларация = граница. Shape — это не декоративная валидация поверх логики, а часть границы доступа к данным.

  2. Pathname-as-variant — ключ выбора варианта, не авторизация. Авторизация всё равно должна быть в resolveVariant или в обработчике авторизации.

  3. ALS — нормально, если заполнение в правильном middleware. Это не магия, это store.run(value, callback) обёрнутый вокруг next().

  4. Сложные дашборды через вложенные селекты — да. 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-схема роутера.

  • findManyGET /{model}/; для длинных запросов есть POST-альтернатива POST /{model}/read (потому что POST /{model}/ занят под create).

  • findFirstGET /{model}/first или POST /{model}/first.

  • findManyPaginatedGET /{model}/paginated или POST /{model}/paginated.

Для длинных дашбордных запросов с вложенными селектами POST-альтернатива удобнее, чем передавать всё через query string.

Дашборд агента, шаги сервера.

GET /api/v1/user/firstx-api-variant: /agent/dashboard
  1. JWT декодируется → userId попадает в ALS

  2. Express маршрутизирует на сгенерированный userRouter.findFirst

  3. prisma-guard смотрит на x-api-variant: /agent/dashboard

  4. Находит shape, вызывает (ctx) => ({...}) с заполненным ctx.userId

  5. Принуждает where: { id: { equals: ctx.userId } }

  6. Применяет вложенный select: профиль, сохранённые объявления, отклики, активность

  7. Prisma собирает и выполняет запросы к БД

  8. Ответ — структура, в точности соответствующая shape

Никаких резолверов. Никаких ручных проверок «а это точно мой userId». Граница описана в одном месте, она и есть безопасность.

Главный вывод: форма запроса должна быть не подсказкой для клиента, а исполняемой границей на сервере. Всё, что не описано в shape, не должно доходить до Prisma.

ссылка на оригинал статьи https://habr.com/ru/articles/1035418/