Run the configured formatter across the repository before audit remediation so later security fixes produce reviewable, conflict-resistant diffs.
17 KiB
Протокол безопасной офлайн-синхронизации Krona Staff
1. Архитектурный контекст и инварианты
Krona Staff является модулем платформы Krona Business Platform, а не изолированной системой пользователей, прав и ролей:
- Единственный источник истины (Single Source of Truth): Центральный сервер Krona Staff и Platform Core являются владельцами эталонных данных. Локальные терминалы, планшеты и клиенты являются исполнительными терминалами и не могут становиться вторым источником истины.
- User != Employee: Учётная запись пользователя платформы (
User) отделена от карточки сотрудника (Employee). - Строгая изоляция арендаторов (Tenant Isolation): Все сущности привязаны к
tenant_id(ULID). Новые таблицы включают и форсируют PostgreSQL Row Level Security (FORCE ROW LEVEL SECURITY), гарантируя невозможность утечки данных между организациями даже на уровне прямого SQL. - Безопасность логов: Секретные токены, хэши паролей, биометрия и персональные данные (ПДН) никогда не попадают в серверные логи.
2. Жизненный цикл устройства и аутентификация
2.1. Регистрация устройства (POST /api/v1/staff/sync/devices)
Регистрация выполняется администратором или руководителем предприятия (требуется сессия руководителя или токен с правом staff:sync:manage / staff:attendance:manage).
Запрос:
{
"device_code": "TERM-MAIN-01",
"name": "Главный вход — Терминал",
"org_unit_id": "01J00000000000000000000001",
"allowed_scopes": ["attendance:sync"]
}
Ответ (201 Created):
{
"device": {
"id": "01J9X1Z2A3B4C5D6E7F8G9H0J1",
"tenant_id": "01J00000000000000000000000",
"device_code": "TERM-MAIN-01",
"name": "Главный вход — Терминал",
"token_hint": "kstaff_dev_01J00...",
"status": "active",
"allowed_scopes": ["attendance:sync"],
"org_unit_id": "01J00000000000000000000001",
"registered_at": "2026-09-26T12:00:00.000Z",
"created_by": "01J00000000000000000000099"
},
"token": "kstaff_dev_01J00000000000000000000000_3f2a1b9c8d7e6f..."
}
Important
Сырой токен
tokenвозвращается клиенту ровно один раз при регистрации. В базе данных (staff_sync_devices) сохраняется исключительно криптографический хэш SHA-256 (token_hash) и безопасная маска (token_hint).
2.2. Аутентификация запросов устройства
Каждый запрос от локального терминала к API синхронизации должен содержать заголовок:
Authorization: Bearer <device_token>
Сервер:
- Вычисляет
SHA-256(device_token). - Извлекает
tenant_idиз префикса токена или заголовкаX-Tenant-ID. - Открывает транзакцию с RLS контекстом
database.tenantTransaction(tenantId, actorId, ...). - Находит устройство по
token_hashв таблицеstaff_sync_devices. - Проверяет
status = 'active'и наличие скоупаattendance:sync. - Асинхронно обновляет
last_seen_at.
2.3. Отзыв устройства (POST /api/v1/staff/sync/devices/:deviceId/revoke)
При компрометации устройства, утере или списании администратор выполняет отзыв. Статус устройства в БД переводится в revoked, фиксируется revoked_at = now().
Все последующие запросы с токеном отозванного устройства немедленно отклоняются со статусом 403 Forbidden и кодом ошибки DEVICE_REVOKED:
{
"statusCode": 403,
"error": "Forbidden",
"message": "Устройство отозвано"
}
3. Монотонный непрозрачный курсор изменений
3.1. Формат курсора
Курсор представляет собой непрозрачную строку вида:
cur_<sequence>_<signature_hash>
Например: cur_105_7a9f8b2c1d.
<sequence>: Монотонный идентификатор последовательности изменений в таблицеstaff_sync_change_log(cursor_seq BIGSERIAL).<signature_hash>: Криптографическая контрольная подпись от арендатора, последовательности и серверной соли (SHA-256(tenant_id + seq + salt)).
Свойства курсора:
- Монотонность: Гарантирует строго возрастающий порядок чтения журнала изменений.
- Независимость от времени: Не зависит от системных часов терминала и защищён от сдвигов локального времени.
- Целостность и изоляция: Попытка передать повреждённый курсор или курсор другого арендатора немедленно отклоняется с ошибкой
INVALID_CURSOR(400 Bad Request).
3.2. Начальная загрузка (Bootstrap, cursor = null)
Когда терминал настраивается впервые или выполняет полный сброс локальной базы данных, он отправляет POST /api/v1/staff/sync/pull с пустым или отсутствующим курсором (cursor: null):
- Сервер формирует снимок текущих актуальных данных для поддерживаемых сущностей (
org_unit,profession,employee,work_session,timesheet_entry,planned_shift). - Для каждой сущности формируется безопасная полезная нагрузка с операцией
upsert. - В ответе возвращается
next_cursor, соответствующий текущему максимальномуcursor_seqжурнала изменений сервера. - После применения начального снимка терминал фиксирует
next_cursorи переходит в режим инкрементального получения дельт.
3.3. Инкрементальная загрузка (Pull, POST /api/v1/staff/sync/pull)
Запрос:
{
"cursor": "cur_105_7a9f8b2c1d",
"limit": 50,
"entity_types": ["employee", "work_session"]
}
Ответ:
{
"changes": [
{
"cursor": "cur_106_3c4d5e6f7a",
"entity_type": "employee",
"entity_id": "01J9X1Z2A3B4C5D6E7F8G9H0J2",
"operation": "upsert",
"revision": 2,
"payload": {
"id": "01J9X1Z2A3B4C5D6E7F8G9H0J2",
"personnel_number": "EMP-042",
"display_name": "Иванов Иван Иванович",
"status": "active",
"profession_id": "01J00000000000000000000010",
"position": "Повар-универсал"
},
"created_at": "2026-09-26T12:05:00.000Z"
}
],
"next_cursor": "cur_106_3c4d5e6f7a",
"has_more": false
}
3.4. Подтверждение изменений (Ack, POST /api/v1/staff/sync/ack)
После успешного локального сохранения изменений устройство подтверждает обработанный курсор:
{
"cursor": "cur_106_3c4d5e6f7a"
}
Сервер сохраняет контрольную точку в таблице staff_sync_device_checkpoints. Это позволяет отслеживать отставание устройств и безопасно выполнять очистку журнала изменений.
4. Пакетная отправка команд (Push) и идемпотентность
4.1. Контракт отправки (POST /api/v1/staff/sync/push)
Клиент отправляет накопленные в офлайне команды пачками.
- Ограничение пакета: от 1 до 100 команд (
BATCH_SIZE_EXCEEDEDпри превышении). - Максимальный размер полезной нагрузки: не более 64 КБ на команду (
PAYLOAD_TOO_LARGE). - Максимальный размер HTTP-запроса: 2 МБ.
- Временное окно защиты от Replay:
created_atкоманды должно быть не старше 30 дней и не более чем на 5 минут в будущем (TIMESTAMP_OUT_OF_WINDOW).
Пример запроса:
{
"commands": [
{
"command_type": "attendance_event",
"schema_version": 1,
"idempotency_key": "cmd-term01-778899",
"correlation_id": "01J9X1Z2A3B4C5D6E7F8G9H0AA",
"created_at": "2026-09-26T12:10:00.000Z",
"payload": {
"employee_id": "01J9X1Z2A3B4C5D6E7F8G9H0J2",
"event_type": "check_in",
"occurred_at": "2026-09-26T12:09:58.000Z",
"source_event_id": "scan-123456"
}
}
]
}
4.2. Идемпотентность (staff_sync_command_inbox)
Таблица staff_sync_command_inbox содержит уникальный ключ:
UNIQUE (tenant_id, device_id, idempotency_key)
При повторной отправке команды с тем же idempotency_key:
- Мутация повторно не исполняется.
- Сервер извлекает ранее зафиксированный результат (
status,result,conflict,error_code,error_message) и возвращает его клиенту. - Это гарантирует полную безопасность при сбоях связи и автоматических повторах (Retry).
5. Обнаружение конфликтов
Для сущностей с контролем версий (work_session, timesheet_entry) используется оптимистическая блокировка через поле revision (целое число > 0).
Если терминал пытается применить мутацию на основе устаревшей ревизии (например, смена уже была подтверждена управляющим на сервере), команда не перезаписывает данные.
Ответ с конфликтом (в составе results):
{
"idempotency_key": "cmd-term01-778900",
"command_type": "upsert_work_session",
"status": "conflict",
"conflict": {
"conflict_code": "REVISION_MISMATCH",
"entity_type": "work_session",
"entity_id": "01J9X1Z2A3B4C5D6E7F8G9H0WS",
"expected_revision": 2,
"actual_revision": 3,
"safe_description": "Ревизия рабочей смены на сервере изменилась",
"client_action": "FETCH_LATEST_AND_RETRY"
}
}
Клиент, получив конфликт, обязан:
- Запросить актуальное состояние сущности через
POST /sync/pull. - Обновить локальное представление.
- При необходимости повторить действие с актуальной ревизией или уведомить оператора.
6. Минимальный безопасный набор данных
Терминал получает исключительно оперативные данные, необходимые для фиксации посещаемости и графиков:
| Сущность | Разрешённые поля | Запрещённые данные |
|---|---|---|
org_unit |
id, code, name, status |
Финансовые показатели, выручка, арендные договоры |
profession |
id, code, name, status |
Тарифные сетки, оклады |
employee |
id, personnel_number, display_name, status, profession_id, position |
Зарплаты, ставки, паспортные данные, СНИЛС, телефоны, адреса, биометрия |
work_session |
id, employee_id, org_unit_id, business_date, started_at, ended_at, worked_minutes, status, revision |
Стоимость смены, начисления, штрафы |
timesheet_entry |
id, employee_id, work_date, minutes_worked, status, revision |
Расчётные листы, итоговые выплаты |
planned_shift |
id, employee_id, org_unit_id, business_date, planned_minutes, start_time, end_time |
Плановая стоимость ФОТ смены |
attendance_event |
id, employee_id, event_type, occurred_at, source_event_id, device_id, org_unit_id, status |
Сырые биометрические сэмплы сканера |
Caution
Финансовые данные и заработная плата НИКОГДА не передаются на локальный терминал! Это исключает риск утечки конфиденциальной финансовой информации при физическом хищении терминала или компрометации его локального хранилища.
7. Безопасность биометрии
- Запрет передачи через Sync API: Биометрические шаблоны (отпечатки пальцев, векторы лиц) строго запрещено передавать через интерфейсы синхронизации Krona Staff.
- Локальная верификация: Если терминал оснащён биометрическим датчиком (например, ZKTeco SLK20R), он выполняет сопоставление биометрии локально в изолированном модуле или безопасном анклаве устройства.
- Фиксация только факта: Сервер получает только сформированное событие
attendance_eventс провереннымemployee_id, типом отметки (check_in/check_out) и временем фиксации.
8. Политика хранения данных (Retention)
Для предотвращения бесконечного роста таблиц синхронизации предусмотрена функция очистки:
SELECT * FROM staff_sync_cleanup_retention(30);
- Удаляет записи
staff_sync_change_logстарше заданного количества дней (по умолчанию 30). - Удаляет обработанные команды
staff_sync_command_inboxстарше заданного количества дней. - Активные устройства, отстающие более чем на окно хранения (30 дней), при следующем обращении с устаревшим курсором получают требование повторной начальной инициализации (Bootstrap).