# Протокол безопасной офлайн-синхронизации 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 ``` Сервер: 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__ ``` Например: `cur_105_7a9f8b2c1d`. - ``: Монотонный идентификатор последовательности изменений в таблице `staff_sync_change_log` (`cursor_seq BIGSERIAL`). - ``: Криптографическая контрольная подпись от арендатора, последовательности и серверной соли (`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). --- ## 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).