Предотвращение несанкционированного создания подтверждённых смен и начислений с устройств синхронизации. Команды upsert_work_session и upsert_timesheet_entry отклоняются сервером со статусом rejected и кодом COMMAND_NOT_SUPPORTED без записи в базу данных. Очистка интеграционного теста выполняется соединением мигратора в соответствии с ограничениями прав роли приложения.
294 lines
18 KiB
Markdown
294 lines
18 KiB
Markdown
# Протокол безопасной офлайн-синхронизации 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).
|