krona-staff/docs/OFFLINE-SYNC-PROTOCOL.md
Ochenstarik 5ee75177b9 feat(sync): add offline sync backend foundation
- Add PostgreSQL migrations with strict RLS policies (0011)
- Introduce SyncService for handling auth, token-based registration
- Add payload size and replay attack mitigations
- Provide HTTP fastify routes (pull, push, ack, device management)
- Validate openapi contracts and add comprehensive tests
2026-09-26 13:10:27 +07:00

16 KiB
Raw Permalink Blame History

Протокол безопасной офлайн-синхронизации Krona Staff

1. Архитектурный контекст и инварианты

Krona Staff является модулем платформы Krona Business Platform, а не изолированной системой пользователей, прав и ролей:

  1. Единственный источник истины (Single Source of Truth): Центральный сервер Krona Staff и Platform Core являются владельцами эталонных данных. Локальные терминалы, планшеты и клиенты являются исполнительными терминалами и не могут становиться вторым источником истины.
  2. User != Employee: Учётная запись пользователя платформы (User) отделена от карточки сотрудника (Employee).
  3. Строгая изоляция арендаторов (Tenant Isolation): Все сущности привязаны к tenant_id (ULID). Новые таблицы включают и форсируют PostgreSQL Row Level Security (FORCE ROW LEVEL SECURITY), гарантируя невозможность утечки данных между организациями даже на уровне прямого SQL.
  4. Безопасность логов: Секретные токены, хэши паролей, биометрия и персональные данные (ПДН) никогда не попадают в серверные логи.

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>

Сервер:

  1. Вычисляет SHA-256(device_token).
  2. Извлекает tenant_id из префикса токена или заголовка X-Tenant-ID.
  3. Открывает транзакцию с RLS контекстом database.tenantTransaction(tenantId, actorId, ...).
  4. Находит устройство по token_hash в таблице staff_sync_devices.
  5. Проверяет status = 'active' и наличие скоупа attendance:sync.
  6. Асинхронно обновляет 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):

  1. Сервер формирует снимок текущих актуальных данных для поддерживаемых сущностей (org_unit, profession, employee, work_session, timesheet_entry, planned_shift).
  2. Для каждой сущности формируется безопасная полезная нагрузка с операцией upsert.
  3. В ответе возвращается next_cursor, соответствующий текущему максимальному cursor_seq журнала изменений сервера.
  4. После применения начального снимка терминал фиксирует 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:

  1. Мутация повторно не исполняется.
  2. Сервер извлекает ранее зафиксированный результат (status, result, conflict, error_code, error_message) и возвращает его клиенту.
  3. Это гарантирует полную безопасность при сбоях связи и автоматических повторах (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"
  }
}

Клиент, получив конфликт, обязан:

  1. Запросить актуальное состояние сущности через POST /sync/pull.
  2. Обновить локальное представление.
  3. При необходимости повторить действие с актуальной ревизией или уведомить оператора.

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. Безопасность биометрии

  1. Запрет передачи через Sync API: Биометрические шаблоны (отпечатки пальцев, векторы лиц) строго запрещено передавать через интерфейсы синхронизации Krona Staff.
  2. Локальная верификация: Если терминал оснащён биометрическим датчиком (например, ZKTeco SLK20R), он выполняет сопоставление биометрии локально в изолированном модуле или безопасном анклаве устройства.
  3. Фиксация только факта: Сервер получает только сформированное событие 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).