krona-staff/docs/OFFLINE-SYNC-PROTOCOL.md
Ochenstarik b283736312 fix(sync): reject work session and timesheet mutations from devices
Предотвращение несанкционированного создания подтверждённых смен и начислений с устройств синхронизации. Команды upsert_work_session и upsert_timesheet_entry отклоняются сервером со статусом rejected и кодом COMMAND_NOT_SUPPORTED без записи в базу данных. Очистка интеграционного теста выполняется соединением мигратора, вставка в staff_employees согласована со схемой миграций.
2026-09-26 16:12:02 +07:00

294 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Протокол безопасной офлайн-синхронизации 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`).
**Запрос:**
```json
{
"device_code": "TERM-MAIN-01",
"name": "Главный вход — Терминал",
"org_unit_id": "01J00000000000000000000001",
"allowed_scopes": ["attendance:sync"]
}
```
**Ответ (201 Created):**
```json
{
"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 синхронизации должен содержать заголовок:
```http
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`:
```json
{
"statusCode": 403,
"error": "Forbidden",
"message": "Устройство отозвано"
}
```
---
## 3. Монотонный непрозрачный курсор изменений
### 3.1. Формат курсора
Курсор представляет собой непрозрачную строку вида:
```text
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`)
Запрос:
```json
{
"cursor": "cur_105_7a9f8b2c1d",
"limit": 50,
"entity_types": ["employee", "work_session"]
}
```
Ответ:
```json
{
"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`)
После успешного локального сохранения изменений устройство подтверждает обработанный курсор:
```json
{
"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`).
**Пример запроса:**
```json
{
"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` содержит уникальный ключ:
```sql
UNIQUE (tenant_id, device_id, idempotency_key)
```
При повторной отправке команды с тем же `idempotency_key`:
1. Мутация повторно не исполняется.
2. Сервер извлекает ранее зафиксированный результат (`status`, `result`, `conflict`, `error_code`, `error_message`) и возвращает его клиенту.
3. Это гарантирует полную безопасность при сбоях связи и автоматических повторах (Retry).
### 4.3. Запрещённые команды
Прямое создание или изменение смен (`upsert_work_session`) и табелей (`upsert_timesheet_entry`) с устройств строго запрещено для предотвращения несанкционированных начислений. При получении таких команд сервер отклоняет их со статусом `rejected` и кодом ошибки `COMMAND_NOT_SUPPORTED` (без записи в базу данных).
---
## 5. Обнаружение конфликтов
Для сущностей с контролем версий (`work_session`, `timesheet_entry`) используется оптимистическая блокировка через поле `revision` (целое число > 0).
Если терминал пытается применить мутацию на основе устаревшей ревизии (например, смена уже была подтверждена управляющим на сервере), команда **не перезаписывает** данные.
**Ответ с конфликтом (в составе `results`):**
```json
{
"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)
Для предотвращения бесконечного роста таблиц синхронизации предусмотрена функция очистки:
```sql
SELECT * FROM staff_sync_cleanup_retention(30);
```
- Удаляет записи `staff_sync_change_log` старше заданного количества дней (по умолчанию 30).
- Удаляет обработанные команды `staff_sync_command_inbox` старше заданного количества дней.
- Активные устройства, отстающие более чем на окно хранения (30 дней), при следующем обращении с устаревшим курсором получают требование повторной начальной инициализации (Bootstrap).