Руководитель отдела открывает Mini App, видит заявку на закупку лицензий и нажимает «Одобрить». В этот момент на экране было написано: 120 000 рублей, пять лицензий. Но пока он листал вложение, автор заявки поправил сумму до 180 000 и добавил ещё три лицензии. Сервер получил нажатие и записал: «заявка одобрена». Вопрос, на который теперь нужно уметь ответить без споров: что именно подтвердил человек?

Если система хранит только статус «одобрено», ответа нет. Согласие оказалось приклеено к номеру заявки, а не к её содержанию. Всё остальное в этой статье — способ сделать так, чтобы решение всегда было привязано к конкретному состоянию, конкретному человеку и разрешённому переходу, причём независимо от того, как выглядит интерфейс.

Telegram знает, кто пришёл, но не знает, кто у вас работает

Mini App открывается внутри Telegram и получает данные о пользователе. Соблазн велик: взять идентификатор из этих данных, найти его в таблице и считать вопрос закрытым. Здесь два разных вопроса, которые нельзя смешивать.

Первый: действительно ли запрос пришёл от этого аккаунта Telegram. Это решается проверкой initData на сервере. Документация Telegram прямо указывает, что проверять нужно именно initData, а initDataUnsafe доверенным считать нельзя; поле auth_date позволяет оценить давность полученных данных (Telegram: Mini App data validation). Алгоритм подписи здесь разбирать не будем: он описан в документации, а в прикладном коде его лучше держать в одной небольшой функции или проверенной библиотеке. Для нашей задачи важен результат: после этой проверки сервер знает Telegram ID с подтверждённым происхождением.

Второй вопрос: является ли этот человек сегодня сотрудником и какие у него полномочия. Telegram ID на него не отвечает. Аккаунт может принадлежать уволенному коллеге, который по-прежнему сидит в рабочем чате. Номер телефона мог смениться, а сам аккаунт остаться. Поэтому между подтверждённым ID и бизнес-действием стоит ваш внутренний справочник: таблица сотрудников со статусом, ролями и датой отзыва доступа.

Практически это выглядит как короткая цепочка на каждом запросе:

  1. Принять заголовок с initData и проверить подпись на сервере.
  2. Отклонить слишком старый auth_date. Допустимое окно выбираете вы; для внутреннего инструмента разумно взять небольшое и обновлять данные при открытии приложения.
  3. Найти запись сотрудника по Telegram ID среди привязанных учётных записей.
  4. Убедиться, что статус сотрудника «активен», а привязка не отозвана.
  5. Загрузить роли и только потом переходить к самому действию.

Привязка Telegram ID к сотруднику — отдельное событие, и его стоит делать осознанно: например, через одноразовое приглашение, которое кадровая служба или администратор выдаёт на конкретного человека. Тогда в таблице появляется связь «сотрудник № 412 — Telegram ID такой-то, привязан тогда-то, кем-то подтверждён». Если привязку делать по принципу «кто первый написал боту», рано или поздно в справочнике окажется не тот человек.

Три роли в одной заявке — три разных человека

Когда в системе пока один сценарий, роли кажутся избыточными: есть пользователи и есть админ. Но у заявки на закупку, отпуск или доступ к системе по меньшей мере три функции, и их полезно развести, потому что от этого зависят проверки.

  • Автор создаёт заявку и может править её, пока она не отправлена на согласование. После отправки автор правит только через явный возврат в черновик.
  • Согласующий принимает решение: одобрить или отклонить. Он не должен совпадать с автором той же заявки, даже если по должности имеет право одобрять такие суммы.
  • Исполнитель получает одобренную заявку и отмечает её выполнение. Исполнитель не вправе менять решение или состав заявки.

Важная деталь: роль — это не свойство человека вообще, а отношение человека к конкретной заявке. Начальник отдела закупок в целом может одобрять заявки до определённой суммы, но свою собственную заявку одобрить не может. Поэтому проверка полномочий складывается из двух частей: общая роль из справочника и связь с конкретной заявкой. Первая отвечает на вопрос «вообще имеет право», вторая — «именно здесь допустимо».

Удобно хранить правило в виде таблицы допустимых действий. Условный пример для заявки на закупку:

Кто и что может делать с заявкой (условный пример)
СостояниеАвторСогласующийИсполнитель
Черновикправить, отправить——
На согласованииотозватьодобрить, отклонить—
Одобрена——взять в работу, закрыть
Отклоненавернуть в черновик——

Эта таблица — не декорация для документации, а источник истины для кода. Серверный обработчик «одобрить» должен сам обратиться к такому правилу и убедиться, что заявка находится в состоянии «на согласовании», а пользователь — подходящий согласующий и не автор. Если прятать кнопку «Одобрить» от автора в интерфейсе, но не проверять это на сервере, защиты нет, а есть только вежливая подсказка.

Решение привязывается к версии, а не к номеру

Вернёмся к сцене из начала. Причина беды — в том, что команда выглядела как «одобрить заявку 1042». Правильная команда выглядит иначе: «одобрить заявку 1042, версия 3». Версия — это счётчик, который растёт при каждом изменении существенных полей: суммы, состава, получателя, срока. Интерфейс получает номер версии вместе с данными заявки и возвращает его вместе с решением.

Сервер принимает решение только в том случае, если версия в запросе совпадает с текущей версией заявки. Если не совпадает, ответ — конфликт: «заявка изменилась, откройте её заново». Согласующий видит новую сумму и решает снова. Это называется оптимистичной блокировкой, и для внутренних заявок она подходит лучше, чем попытка заблокировать запись на время, пока человек читает вложение: люди читают долго и могут вообще закрыть приложение.

Рабочая схема данных может быть такой:

requests(id, author_id, state, version, payload, updated_at)
request_versions(request_id, version, payload, created_by, created_at)
decisions(id, request_id, version, approver_id, outcome, comment, decided_at)
audit_log(id, at, actor_id, action, request_id, from_version, to_version, details)

Ключевая идея: в request_versions сохраняется снимок содержимого каждой версии, а в decisions лежит ссылка на пару «заявка, версия». Через месяц можно открыть решение и показать ровно ту редакцию заявки, которую видел согласующий, даже если потом заявку сто раз правили.

Само принятие решения — один атомарный шаг в вашей базе данных. Условное обновление состояния звучит примерно так: «перевести заявку 1042 из состояния „на согласовании“ в „одобрена“, если её версия равна 3». Если обновление затронуло ноль строк, значит, что-то поменялось: версия, состояние или и то и другое. Запись в таблицу решений и запись в журнал делаются в той же транзакции вашей базы, поэтому не бывает ситуации «статус изменился, а следа нет». Но это относится только к данным внутри вашей БД. Если после одобрения нужно позвонить во внешний сервис или отправить уведомление в Telegram, это отдельный шаг, который не входит в ту же транзакцию, и о его повторе надо думать отдельно.

Путь одной заявки и конфликт двух решений

Проследим условную заявку: Анна из отдела разработки просит закупить пять лицензий на 120 000 рублей. Назовём её заявкой 1042.

  1. Анна создаёт черновик. Версия 1, состояние «черновик». В журнале: Анна создала заявку.
  2. Анна отправляет заявку на согласование. Состояние «на согласовании», версия 1. Назначены два возможных согласующих: Борис и Вера, потому что в отделе правило «любой из руководителей группы».
  3. Борис открывает заявку на телефоне, видит версию 1 и долго читает вложение.
  4. Пока он читает, Анна замечает ошибку в расчёте и меняет сумму на 180 000. Это существенное поле, поэтому версия становится 2, а заявка остаётся на согласовании. Прежние решения по версии 1, если они были, считаются недействительными.
  5. Вера в это время открывает заявку, видит версию 2 с суммой 180 000 и нажимает «Одобрить». Запрос уходит с версией 2.
  6. Борис нажимает «Одобрить» со старого экрана. Запрос уходит с версией 1.

Что должен сделать сервер, получив эти два запроса? Решение Веры пришло с актуальной версией, и состояние «на согласовании» совпадает: заявка переходит в «одобрена». Решение Бориса пришло с версией 1, а текущая версия — 2, да и состояние уже не «на согласовании». Сервер отвечает конфликтом, ничего не записывает в решения и добавляет в журнал отклонённую попытку: кто, когда, с какой версией и почему отказано. Борис видит сообщение «заявка изменилась и уже одобрена другим согласующим» и может открыть историю.

Порядок прихода запросов мог быть обратным. Если бы Борис успел раньше и его запрос с версией 1 попал на сервер до правки Анны, решение стало бы действительным для версии 1. Тогда правка Анны после одобрения должна быть отдельным конфликтом: изменение существенных полей одобренной заявки либо запрещено, либо возвращает заявку в состояние «на согласовании» с новой версией и сбросом прежнего одобрения. Какая политика верна, решаете вы, но она обязана быть явной и одинаковой для всех заявок. Молчаливая правка суммы под уже поставленной галочкой — худший из вариантов.

Backend не верит интерфейсу, даже своему

Теперь самое неприятное для тех, кто привык считать Mini App закрытым миром. Mini App — это веб-страница. У страницы есть адреса API, и эти адреса можно вызвать не только из Telegram: из терминала, скрипта, браузерной консоли. Если у обработчика «одобрить» нет собственных проверок, то достаточно знать адрес и номер заявки.

Поэтому каждая операция проходит одинаковый серверный путь, не зависящий от того, откуда пришёл запрос:

  1. Проверка подписи initData и давности auth_date. Запрос без валидных данных отклоняется как неавторизованный.
  2. Поиск активного сотрудника по подтверждённому Telegram ID.
  3. Загрузка заявки и проверка отношения сотрудника к ней: автор, согласующий, исполнитель.
  4. Проверка допустимости перехода: действие разрешено для этой роли в текущем состоянии.
  5. Проверка версии из запроса против текущей версии.
  6. Атомарная запись нового состояния, решения и журнала.

Допустим, злоумышленник или просто любопытный коллега отправил прямой запрос «одобрить заявку 1042, версия 2» со своими подтверждёнными данными Telegram, но без роли согласующего. Шаг 3 вернёт запрет. Если он попробует подставить чужой Telegram ID в тело запроса, подпись initData не совпадёт, потому что идентификатор берётся только из проверенных данных, а не из полей, присланных клиентом. Отдельно стоит договориться, что API никогда не принимает от клиента «я — такой-то сотрудник» и «у меня роль такая-то»: ни то ни другое сервер не читает из тела запроса.

То же относится к скрытию данных. Если сотрудник без права видеть суммы получает список заявок, сервер не должен отдавать поле суммы в JSON и надеяться, что фронтенд его не покажет. Отдавать надо ровно то, что роль вправе видеть.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации Россия, США, Франция и Великобритания. Оплата картой РФ и по СБП.

АРЕНДОВАТЬ СЕРВЕР

Права в базе данных: кто вообще может править журнал

Журнал аудита ценен только тогда, когда его нельзя незаметно переписать. Здесь помогает устройство прав в самой СУБД. В PostgreSQL SELECT, INSERT, UPDATE и DELETE — разные права, и выдавать их можно по отдельности. Кроме того, владелец объекта обладает полномочиями, которые не сводятся к выдаваемым правам чтения (PostgreSQL: Privileges).

Отсюда практические выводы для нашего приложения:

  • Учётная запись, под которой работает backend, получает на таблицу audit_log только INSERT и, при необходимости, SELECT. Права UPDATE и DELETE ей не выдаются, поэтому даже ошибка в коде или внедрённая команда не сможет переписать историю этой учётной записью.
  • Владельцем таблиц должна быть отдельная учётная запись, которая используется для миграций и не используется приложением. Раз владелец имеет полномочия сверх обычных прав, использовать его как рабочую учётную запись сервиса — значит свести защиту журнала на нет.
  • Таблицу decisions тоже стоит сделать «только добавляющей»: решение не редактируют, а при необходимости добавляют новое, отменяющее. Тогда в истории видна цепочка, а не затёртое прошлое.

Это не абсолютная защита: тот, кто владеет базой или сервером целиком, может многое. Но против самых распространённых сценариев — ошибки в обработчике, лишней команды в отладочном скрипте, любопытного разработчика с рабочей учётной записью приложения — разделение прав работает хорошо и стоит несколько строк в миграции.

Что записывать в журнал, чтобы он что-то объяснял

Журнал, который содержит только «пользователь 17 нажал кнопку в 14:03», бесполезен. Запись должна отвечать на вопросы: кто, что сделал, над чем, из какого состояния в какое, и какой результат получил. Минимальный набор полей:

  • время события по серверным часам, а не по часам клиента;
  • внутренний идентификатор сотрудника и роль, в которой он действовал, а также Telegram ID, подтверждённый на этом запросе;
  • действие: создать, изменить, отправить, одобрить, отклонить, отозвать, закрыть;
  • заявка, версия до и версия после;
  • результат: выполнено или отклонено, а при отклонении — причина: нет прав, версия устарела, недопустимый переход;
  • для изменений — какие поля менялись и их прежние и новые значения, если они не секретны.

Отдельно стоит записывать отказы. Попытка одобрить чужую заявку или подставить старую версию — важный сигнал: это может быть обычное устаревшее окно, а может быть и пробное нащупывание границ. Без записи об отказах вы увидите только успешные операции и получите искажённую картину.

Чувствительные данные в журнал не копируйте целиком. Если в заявке есть паспортные данные или сведения о здоровье, в журнале достаточно указать, что поле менялось. Журнал читают чаще, чем сами заявки, и права на него нередко шире.

Три неприятных случая: двойной тап, увольнение, правка после одобрения

Повторное нажатие

Мобильная сеть может задержать ответ, и сотрудник нажмёт «Одобрить» второй раз, а клиентская библиотека может сама повторить запрос. Если сервер обрабатывает команду как «записать ещё одно решение», в таблице появятся два одобрения одной версии, а в журнале — двойное событие.

Спасает то самое условное обновление состояния. Первый запрос переводит заявку из «на согласовании» в «одобрена» с версией 2. Второй запрос приходит с тем же «одобрить, версия 2», но состояние уже другое, и переход «из согласования» недоступен. Сервер не создаёт второе решение. Разумно отвечать повтору не ошибкой, а понятным сообщением вроде «это решение уже зафиксировано вами в 14:03», если тот же сотрудник уже принял то же решение по той же версии. Если же заявку одобрил другой человек, ответ — конфликт, как у Бориса выше.

Важно не полагаться на то, что клиент пришлёт уникальный идентификатор попытки и этого достаточно. Сам по себе новый идентификатор повтор не отсекает: отсекает проверка состояния и версии, а идентификатор попытки полезен лишь как дополнительный след в журнале.

Увольнение согласующего

Борис уволился в понедельник. В понедельник вечером у него в Telegram всё так же открыт Mini App. Если авторизация проверяла бы только подпись initData, он мог бы одобрить что угодно: подпись-то настоящая, это его аккаунт. Именно поэтому на каждом запросе сервер сверяется со справочником сотрудников: статус «активен» и привязка не отозвана. Кадровое событие «уволен» должно сразу отзывать доступ: меняется статус, привязка помечается отозванной, а активные сессии перестают проходить шаг 4 проверочной цепочки.

Остаётся вопрос о заявках, которые ждали решения Бориса. У них нужна политика переназначения: заявка передаётся его заместителю или руководителю, а в журнале остаётся событие «согласующий изменён, причина — отзыв доступа». Решения, которые Борис принял, пока работал, остаются в истории неизменными: они были верными на момент принятия. Это ещё одна причина хранить в журнале роль и статус на момент действия, а не вычислять их задним числом по сегодняшнему справочнику.

Если задержка в обновлении справочника возможна (например, он синхронизируется с кадровой системой раз в час), надо понимать окно риска и сокращать его: дать администратору кнопку немедленного отзыва, а в самом Mini App не кэшировать роли на стороне клиента.

Изменение заявки после одобрения

Вернёмся ко второму развилку из истории с Анной. Допустим, заявка уже одобрена, и Анна хочет увеличить количество лицензий. Есть три честных политики:

  • Запретить правку. Нужны изменения — создаётся новая заявка со ссылкой на прежнюю. Самый простой вариант для аудита.
  • Вернуть на согласование. Правка создаёт новую версию, прежнее решение теряет силу и остаётся в истории как решение по прошлой версии. Согласующий видит различия между версиями.
  • Разрешить мелкие правки без пересогласования. Для несущественных полей вроде комментария. Список существенных полей задаётся заранее и проверяется на сервере, а не определяется на лету.

Какую бы политику вы ни выбрали, правило должно быть кодом и таблицей, а не договорённостью в чате. Тогда любая спорная ситуация разбирается по журналу, а не по памяти участников.

Где всё это живёт: скромный сервер и аккуратная база

Такая система по нагрузке обычно скромна: несколько десятков или сотен сотрудников, короткие запросы, небольшая база заявок и журнала. Для неё нужны сервер приложения, реляционная база, такая как PostgreSQL, и способ принимать HTTPS-запросы от Mini App. Всё это помещается на небольшом виртуальном сервере, а запас под рост определяется замером на ваших данных, а не обещанием из таблицы тарифов.

Для первого развёртывания разумно смотреть на VPS с root-доступом, где вы сами настраиваете учётные записи базы, права на таблицы и сетевые правила. Это как раз то, что нужно для разделения владельца схемы и рабочей учётной записи приложения. По публичному предложению VPS MAATRIX построены на NVMe и AMD EPYC, предоставляют root-доступ, доступны в локациях UK, US, RU и FR, а оплата принимается картой РФ и через СБП с автоматическим созданием сервера в течение пары минут после оплаты. Для внутреннего сервиса из нескольких десятков человек начальным ориентиром могут быть младшие конфигурации: Ba (1 vCPU, 2 ГБ RAM, 40 ГБ NVMe) или Uab (2 vCPU, 4 ГБ, 80 ГБ), если база и приложение стоят на одном сервере. Если журнал и вложения будут быстро расти, выбор конфигурации лучше пересмотреть после наблюдения за реальной нагрузкой. Нужные образы и их наличие в выбранной локации проверяйте в форме заказа.

Место для сервера стоит выбрать с учётом того, где находятся сотрудники и какие требования предъявляют к хранению персональных данных ваша организация и применимое право: расположение сервера само по себе соответствия закону не обеспечивает. Резервные копии базы заявок и журнала настройте самостоятельно и проверьте восстановление: в публичных фактах о тарифах автоматические копии для всех конфигураций не заявлены.

Если вас интересует схожая по духу задача с расписанием и бронированием, у нас есть отдельный разбор: Telegram Mini App для записи на услуги: расписание, бронирование и VPS. Там конфликт двух людей возникает не из-за суммы, а из-за одного свободного слота, и многие идеи о состоянии и версии перекликаются.

История одного согласования, по которой всё понятно

Через три месяца финансовый контролёр спрашивает: почему заявка 1042 была оплачена на 180 000, если Анна просила 120 000, и кто это одобрил? Без журнала ответ превратился бы в раскопки в чатах. С журналом он читается как короткий рассказ.

  1. 10:02 — Анна создала заявку 1042, версия 1: пять лицензий, 120 000 рублей.
  2. 10:15 — Анна отправила заявку на согласование; назначены Борис и Вера.
  3. 10:31 — Борис открыл версию 1, решения не принял.
  4. 10:38 — Анна изменила сумму и количество; создана версия 2: восемь лицензий, 180 000 рублей. Причина в комментарии: ошибка в расчёте, добавлены три лицензии для новых сотрудников.
  5. 10:44 — Вера открыла версию 2 и одобрила её. Роль: согласующий, активный сотрудник. Переход «на согласовании» в «одобрена».
  6. 10:46 — попытка Бориса одобрить версию 1 отклонена: версия устарела, заявка уже одобрена. Записано его сообщение о конфликте.
  7. 11:20 — исполнитель Глеб взял заявку в работу; в 15:05 отметил закупку выполненной.

Из этой истории сразу видно главное: оплаченная сумма совпадает с версией, которую увидела и одобрила Вера; изменение сделала автор заявки до этого одобрения; единственное «лишнее» нажатие отклонено и не стало решением. Каждую строку можно сопоставить с записью в таблицах версий и решений, а снимок версии 2 хранит именно те восемь лицензий и ту сумму, которые Вера видела на экране.

Если бы в тот день Борис всё же нажал «Одобрить» первым, история читалась бы иначе, но так же однозначно: решение по версии 1, затем правка, возврат на согласование, новое решение. Смысл принятого решения восстанавливается не из памяти людей и не из того, как выглядел экран, а из пары «версия и человек», записанной в тот момент, когда он нажал кнопку.