Эта страница переведена с английского. Читать страницу на английском
Сквозные возможности для бизнес-приложений: аудит данных, защита персональных данных, безопасные развёртывания, проверки работоспособности, безопасность сессий, импорт CSV и XLSX, фоновые задачи и процессы согласования. Всё проверяется на сервере; браузерный runtime лишь делает работу удобнее. Имеются в виду законы о приватности KVKK и GDPR, но ничто здесь не привязано к стране или отрасли.
Аудит данных на уровне полей
AuditLog записывает один AuditEntry на каждую отправку формы и на каждый вызов action с пометкой audit. Для сохранений запись также называет запись данных и каждое изменённое поле:
{"kind":"form","screen":"customer","action":"save_customer","ok":true,"user":"ayse",
"model":"Customer","record":"2","changes":[{"field":"name","old":"Customer 2","new":"Renamed"}]}- Формы делают это сами: старые значения берутся из записи, которую экран загрузил на сервере, новые из проверенного
Valid<M>. Ничто из присланного клиентом не используется как старое значение, а поля, которые форма не принимает от клиента, никогда не появляются как изменённые. - Сохранения через API записывают модель и ключ из своего входного
Valid<M>. Только action знает, что лежит в базе данных, поэтому разницу он сообщает сам:
#[action(permission = "crm.customer.write", audit)]
pub async fn save_customer(c: Valid<Customer>, store: Customers, ctx: Ctx) -> Result<Customer> {
let current = store.get(c.id)?;
ctx.record_changes(¤t, &c);
store.save(c.into_inner())
}То же самое в action сохранения форм допустимо: отчёт action заменяет отчёт формы.
- Значения это простые строки, не зависящие от языка (
1234.56 TRY,2024-05-01, значения выбора). Версии строк опускаются. Маскированные поля остаются маскированными (см. ниже). - Неудавшееся сохранение всё равно называет запись, без изменений.
Маскирование полей
Помечайте текстовые поля с персональными данными через #[mask]. Здесь национальный идентификатор показывается как •••• 8901, email как p••••@example.com, а заметки как ••••:
#[entity(table = "people")]
pub struct Person {
#[key]
pub id: PersonId,
pub name: String,
#[mask(last4)]
pub national_id: String,
#[mask(email, permission = "hr.contact.unmask")]
pub email: Option<String>,
#[mask]
pub notes: Option<String>,
}- Маскирование происходит на сервере: в ячейках таблицы, формах, выводе API, инструментах агентов и, следовательно, в экспорте CSV, который строится из ячеек. Исходное значение никогда не попадает в браузер или клиент API без права.
- Право на снятие маски это
<model>.<field>.unmaskв snake case (person.national_id.unmask), если атрибут не называет другое. - Пользователи с этим правом сначала всё равно видят маскированный текст с кнопкой раскрытия. Раскрытие вызывает
POST /_zeyn/reveal, который снова проверяет право и записывает запись аудита (kindreveal, поле вrevealed). Отклонённые попытки тоже попадают в аудит. - Клиенты API с этим правом получают исходные значения; каждое такое поле перечисляется в
revealedзаписи аудита вызова. - Формы показывают маскированные поля как текст только для чтения. Сохранённое значение остаётся при сохранении, если только пользователь не вправе раскрыть его и не изменил его после раскрытия. Пустое маскированное поле это обычное поле ввода, поэтому сотрудники, вносящие данные, могут заполнить его один раз, не имея возможности прочитать его потом.
- Фильтры и сортировки по маскированному полю игнорируются для пользователей, которым нельзя его раскрывать, поэтому значение нельзя угадать поиском. Это верно для каждого чтения таблицы (страницы, экспорт, массовые действия по всем строкам, сохранённые представления, инструменты агентов) и для action API, принимающих
DataQuery. Не добавляйте.filter()к маскированным столбцам. - Курсоры постраничного вывода никогда не содержат значения маскированного поля. Список, отсортированный по маскированному полю, разбивается на страницы по позиции, а не по ключу.
- Таблица никогда не группирует по маскированному полю;
group_byпо такому полю игнорируется и записывается в журнал. - Вывод island и других источников данных маскируется на любой глубине, как вывод API: маскированные поля каждой вложенной модели выходят как маскированный текст, а ключи чувствительных моделей записываются как чтения.
- Раскрытие в таблице ищет строку через action таблицы с фильтром по ключу, поэтому action должен учитывать фильтры по своему полю ключа.
#[mask]компилируется только дляStringиOption<String>.
Чтение чувствительных записей
Записывается не только кто изменил персональные данные, но и кто и когда их видел.
#[entity(table = "cases", sensitive)](или#[sensitive]под#[entity]) помечает модель. Каждый раз, когда пользователю показывают такую запись, запись аудита вызова перечисляет её вreads({"model":"Case","id":"2"}): строки страницы таблицы, форму, результат инструмента агента и вывод API. Чтения записываются там, где данные рендерятся, поэтому компоненты внутри layout тоже учитываются.#[screen(..., sensitive)]записывает каждое открытие экрана как чтение, гдеmodelэто имя экрана, аidего параметры, для экранов, показывающих персональные данные, не привязанные к одной модели.#[screen(..., reason)]дополнительно спрашивает у пользователя причину, прежде чем показать какие-либо данные. Сервер отвечает428с кодомreason_required(страница с формой причины при полной загрузке страницы) странице, её фрагментам, таблице, форме и вызовам раскрытия, пока причина не указана. Runtime спрашивает в диалоге, отправляетPOST /_zeyn/reason {screen, reason}(от 3 до 300 символов) и повторяет запрос. Причина хранится 15 минут в cookieHttpOnly,SameSite=Strict, открывает только тот экран, для которого была дана, и записывается вreasonкаждой записи аудита этого экрана. Агенты и клиенты API не могут указать причину, поэтому не могут открывать такие экраны.- Экспорт (CSV, XLSX, PDF) таблицы по чувствительной модели записывает по одной записи аудита на файл с заполненным
export: модель, формат, число строк, а также запрошенные сортировка и фильтры. Они не перечисляют каждую строку вreads, поэтому большой экспорт остаётся дешёвым; экспорт других моделей ничего дополнительного не стоит. Экспорт с экранаsensitiveилиreasonтакже записывает чтение экрана. - Ленивые части экрана (
POST /_zeyn/node, используется ленивыми вкладками) запускают перехватчики (interceptors) и записывают чтения точно так же, как открытие экрана. ctx.record_read(&record)записывает чтение из кода приложения.- Записи чтения пишет
AuditLogдля любого вызова, включая загрузки страниц, как только была показана чувствительная запись. Без перехватчика аудита ничего не записывается; приложения, хранящие персональные данные, должны всегда его регистрировать.
Процессы согласования (Approval workflows)
Workflow это небольшой типизированный конечный автомат. Состояния это enum с #[choice]; каждый переход называет своё начальное и конечное состояние и нужное ему право:
#[choice]
pub enum LeaveState { Draft, Submitted, Approved, Rejected }
pub fn leave_flow() -> Workflow<LeaveState> {
Workflow::new()
.transition("submit", LeaveState::Draft, LeaveState::Submitted, "people.leave.request")
.transition("approve", LeaveState::Submitted, LeaveState::Approved, "people.leave.approve")
.transition("reject", LeaveState::Submitted, LeaveState::Rejected, "people.leave.approve")
}
#[action(permission = "people.leave.request", audit)]
pub async fn move_leave(req: TransitionRequest<LeaveRequestId>, store: LeaveStore, ctx: Ctx) -> Result<LeaveState> {
let mut leave = store.get(req.id)?;
leave.state = leave_flow().apply(&ctx, leave.state, &req.transition, req.comment.as_deref())?;
store.save(leave)
}applyвыполняется на сервере с состоянием, прочитанным из хранилища. Неизвестный переход это404, запись, которая уже не в начальном состоянии, это409(кто-то переместил её первым), отсутствие права это403, комментарий длиннее 1000 символов это422.- Каждый переход записывается в запись аудита в
transition: имя, from, to и комментарий; пользователь и время находятся в самой записи. Transitions::of(flow, state, id, action)показывает текущее состояние, необязательный комментарий и по одной кнопке на каждый переход, который пользователь может сделать из него (Workflow::allowed). Ключ записи берётся с экрана на сервере, поэтому клиент не может переместить другую запись. Агенты видят это как инструмент формы.- Подписи кнопок берутся из
workflow.<name>в каталогах;.label(...)задаёт подпись вручную. Workflow::checkсообщает о неверных правах и о переходах, объявленных дважды из одного состояния; вызывайте его в тесте.- Заявки на отпуск в showcase используют это: сохранить как черновик, отправить, затем утвердить или отклонить.
Безопасные развёртывания
- Каждый ответ несёт
x-zeyn-build, а каждая страница<meta name="zeyn-build">. Идентификатор этоZEYN_BUILD_ID, если задан (буквы, цифры,.,_,-, до 64), иначе хеш исполняемого файла.zeyn::server::build_id()возвращает его, для журналов. - Когда runtime видит ответ другой сборки, он показывает вежливое уведомление: доступна новая версия, перезагрузите, когда будете готовы. Сам он никогда не перезагружает.
- То, что пользователи вводят в формах, хранится в
sessionStorageпо экрану, параметрам и форме и возвращается после перезагрузки с заметкой и кнопкой отказа. Маскированные поля, поля пароля, файлов и скрытые поля никогда не сохраняются. Черновик хранит версию строки, на которой он набирался, поэтому сохранение черновика записи, которую тем временем кто-то изменил, отклоняется как конфликт. Успешное сохранение очищает черновик; черновики старше суток отбрасываются.sessionStorageживёт только в этой вкладке браузера.
Проверки работоспособности (Health checks)
GET /_zeyn/healthотвечает200 ok, пока процесс работает. Используйте его для liveness.GET /_zeyn/readyодновременно запускает все проверки, добавленные черезApp::readiness(name, check), по пять секунд каждая, и отвечает200 {"status":"ready"}или503 {"status":"not_ready","failed":["database"]}. Показываются только имена проверок, никогда ошибки.- Оба находятся под
base_path(/billing/_zeyn/health), отвечают сCache-Control: no-storeи не требуют входа. - Результат readiness используется повторно в течение одной секунды (
READY_CACHE), и проверки запускает только один запрос за раз, поэтому поток проб не может нагрузить базу данных. - Health никогда не ограничивается по частоте, поэтому занятая проба не может заблокировать сама себя и перезапустить здоровое приложение. Readiness учитывается в лимитах запросов, если только не приходит с адреса из
probe_ips. - Правила IP по-прежнему действуют:
deny_ipsвсегда,allow_only_ipsтоже, если только адрес пробы не указан вApp::probe_ips([...]), который открывает для него только эти два endpoint-а.
Задержка при разработке
App::dev_latency(Duration::from_millis(400)) задерживает каждый вызов (страницы, фрагменты, таблицы, формы, API, MCP и вызовы runtime; не статические ресурсы и не пробы health), чтобы разработчики видели состояния загрузки и двойные щелчки так, как их видят пользователи в медленной сети. Это работает только в debug-сборках: в release-сборках метод ничего не делает, а код задержки вырезан при компиляции, поэтому случайно оставленный вызов не замедлит продакшен. Пока он включён, при запуске выводится предупреждение.
Безопасность сессий
Вход приходит от прокси единого входа, встроенного входа OpenID Connect или bearer-токенов (см. docs/auth.md), поэтому этим возможностям нужно лишь, чтобы аутентификатор сообщал, кто пользователь, а для повторной аутентификации, когда он вошёл (Identity::authenticated_at; ForwardAuth::auth_time("x-auth-time") читает Unix-секунды или RFC 3339; DevAuth сообщает каждый запрос как только что вошедший).
App::idle_timeout(Duration)заставляет runtime предупреждать пользователя заidle_warningдо конца (по умолчанию одна минута) в диалоге alert с обратным отсчётом и кнопкой «остаться в системе», которая вызываетPOST /_zeyn/keepalive, чтобы стоящий перед приложением прокси видел активность. Ввод в любой вкладке приложения засчитывается. По истечении пользователь попадает наlogout_url, иначе наlogin_urlсreturn_to, иначе видит экран выхода. Черновики форм сохраняются. Таймаут бездействия лишь закрывает забытые экраны; завершение настоящей сессии это дело провайдера идентификации.#[action(permission = "...", reauth)]требует входа не раньше, чемApp::reauth_withinназад (по умолчанию пять минут). Иначе сервер отвечает401с кодомreauth_required(тип проблемыurn:zeyn:problem:reauth_requiredв API), что бы ни присылал клиент. Runtime просит пользователя войти снова в новой вкладке (reauth_url, иначеlogin_url) и повторяет запрос, когда он возвращается, так что набранное не теряется. Identity без времени входа никогда не проходят.
Импорт CSV
#[action(permission = "people.leave.import", audit)]
pub async fn import_leaves(rows: Vec<Valid<LeaveRequest>>, store: LeaveStore) -> Result<usize> { ... }
Screen::new("Import").body(Import::of(import_leaves).max_rows(1000))- Пользователь выбирает файл CSV. Runtime читает его как UTF-8, а если он не является корректным UTF-8, как Windows-1254 (турецкие электронные таблицы), и отправляет текст в теле JSON на
POST /_zeyn/import(multipart не нужен; не более 8 МБ). Сервер отвечает сводкой, предпросмотром первых десяти строк, ошибками по строкам и скачиваемым отчётом об ошибках. - Разбор: метка порядка байтов отбрасывается,
,,;или табуляция определяются по строке заголовка, кавычки"с разделителями и переводами строк внутри,\r\nили\n. - Лимиты проверяются во время чтения файла, а не после: чтение останавливается на лимите строк, заголовок более чем из 512 ячеек отклоняется, а строки данных сохраняют не более одной ячейки за пределами заголовка (так что лишние значения всё равно сообщаются). Файл XLSX читает не больше столбцов, чем в заголовке, его заголовок может называть не больше столбцов, чем может импортировать модель, а часть shared strings более 16 МБ отклоняется до какой-либо распаковки.
- Столбцы сопоставляются по имени поля или подписи на любом языке приложения, без учёта регистра и турецких i с точкой и без точки. Неизвестные столбцы, столбцы только для чтения, ключи и версии строк отклоняются.
- Каждая строка читается и проверяется точно так же, как форма модели: значения в том виде, как их вводит пользователь этого языка (
1.234,56,05.12.2026), правила полей и собственныйvalidateмодели. - Импорт отправляет в action только корректные строки;
.all_or_nothing()не импортирует ничего, пока каждая строка не корректна. При импорте сервер читает и проверяет файл заново; ничто из предпросмотра не принимается на веру. Оба шага требуют права action, а импорт попадает в аудит. .max_rows(n)ограничивает строки (по умолчанию 5000, никогда не более 50000). Отчёт об ошибках обезвреживает формулы электронных таблиц. Маскированные поля показываются в предпросмотре маскированными.
Импорт XLSX
С Cargo-feature xlsx тот же компонент принимает и книги Excel (.xlsx). Runtime отправляет файл в base64 ("xlsx" вместо "csv" в том же теле JSON), а сервер читает первый лист с помощью calamine в ту же построчную проверку, предпросмотр и отчёт об ошибках, что и для CSV.
- Ячейки сохраняют свои типы: число читается как число, а ячейка даты как дата, независимо от языка пользователя, поэтому
1234.5в турецкой сессии остаётся 1.234,50. Время по настенным часам в ячейке даты читается в часовом поясе пользователя, как это делает форма. Варианты выбора сопоставляются по значению или по подписи. - Лимиты: файл не более 8 МБ, архив может распаковываться не более чем в 64 МБ не более чем в 1000 частей, что проверяется по размерам, объявленным в архиве, до какой-либо распаковки. Ячейки читаются по одной, а чтение останавливается на лимите строк, поэтому лист на миллион строк стоит не больше лимита. Файл, который не является книгой, защищён паролем или обрезан, получает понятную ошибку.
- Читается только первый лист. Формулы учитываются по их кэшированному значению.
Фоновые задачи (Background jobs)
App::new("Billing")
.job_concurrency(2)
.job("send_reminders", Schedule::every(Duration::from_secs(3600)).retry(3, Duration::from_secs(30)), send_reminders)
.job("rebuild_index", Schedule::manual(), rebuild_index)
.jobs_permission("ops.jobs.read")
.serve_until("0.0.0.0:3000", shutdown_signal())- Задача это
async fn(Ctx) -> Result<()>. Её контекст содержит сервисы приложения, конфигурацию и язык по умолчанию, а также identityjob:<name>без прав. Schedule::every(d)запускает по интервалу;Schedule::manual()только по запуску вручную. Action запускают задачи через внедряемый сервисJobs:jobs.trigger("rebuild_index"). Задача, запущенная во время работы, после неё запускается ещё раз; задача никогда не выполняется дважды одновременно..retry(times, first_delay)повторяет неудавшийся запуск, удваивая ожидание до десяти минут.- Одновременно работает не более
job_concurrencyзадач (по умолчанию четыре). - Статус (состояние, запуски, сбои, последний старт и конец, код последней ошибки) доступен как
jobs.status()в коде иGET /_zeyn/jobsдля пользователей сjobs_permission. Подробности ошибок идут только в журнал сервера. App::serve_until(addr, signal)прекращает принимать запросы, когда сигнал завершается, даёт выполняющимся запросам закончиться, отменяет ожидания повторов и даёт выполняющимся задачам 30 секунд.- Без хранилища задачи находятся в памяти: работа, которая ждёт или выполняется при остановке процесса, теряется, а при нескольких серверах каждый ведёт своё расписание.
Надёжные задачи (Durable jobs)
App::new("Billing")
.job_store(SqlJobStore::new(db))
.queued_job("send_invoice", Schedule::manual().retry(5, Duration::from_secs(10)), send_invoice)
.job("close_day", Schedule::every(Duration::from_secs(86_400)), close_day)
async fn send_invoice(ctx: Ctx, invoice_id: String) -> Result<()> { ... }
jobs.enqueue("send_invoice", "42").await?;JobStoreхранит запуски:enqueue,schedule,claimс арендой (lease; идентификатор воркера и время окончания),heartbeat,completeиfail.MemoryJobStore(не более 10 000 запусков, самый старый неудавшийся запуск удаляется первым) используется по умолчанию;SqlJobStore(featurespostgres,oracle) хранит запуски вzeyn_jobs.create_job_tables(dialect, prefix)выдаёт операторы для миграции.- При заданном хранилище задачи
Schedule::everyхранят свой следующий запуск в хранилище, поэтому перезапуск его не сдвигает, а каждый интервал выполняет только один сервер.triggerтоже ставит туда запуск в очередь. - Задачи
queued_jobполучают payload каждого запуска (не болееMAX_JOB_PAYLOAD, 4000 байт); обычные задачи тоже можно ставить в очередь, и они его игнорируют.enqueue_afterоткладывает запуск. - Claim берёт наступившие запуски задач, известных этому серверу, сначала самые старые, и удерживает их
JOB_LEASE(60 секунд), продлевая каждые 20 секунд, пока запуск работает.completeиfailменяют запуск, только пока воркер всё ещё его держит. Запуск, воркер которого остановился, забирается снова после окончания его аренды; потерянная попытка засчитывается, поэтому запуск, валящий свой сервер, заканчивается как неудавшийся. - Неудавшаяся попытка ждёт backoff (удваивается, не более
MAX_BACKOFF_MS, десять минут), пока остаются попытки, а затем остаётся какfailedсо своим кодом ошибки. Расписание переходит к следующему интервалу. - Воркеры ищут наступившие запуски каждые
JOB_POLL(одна секунда) и сразу послеenqueueна том же сервере. Каждый claim берёт не больше запусков, чем свободных слотов задач, поэтому память не растёт вместе с очередью. - PostgreSQL забирает одним
UPDATE ... WHERE id IN (SELECT ... FOR UPDATE SKIP LOCKED) RETURNING. Oracle 11g и новее выбирает идентификаторы сROWNUMиFOR UPDATE SKIP LOCKEDв транзакции, обновляет их и читает обратно до commit. Oracle не умеет упорядочивать блокирующий select, поэтому наступившие запуски там берутся в произвольном порядке. Время хранится в Unix-миллисекундах в столбцахBIGINTилиNUMBER(19). - При хранилище запланированный и запущенный вручную запуск одной и той же задачи могут пересечься; задачи, которые не должны выполняться дважды одновременно, должны защищаться сами (например, блокировкой строки).