# Внешний доступ: единый вход и выгрузка в SIEM

Документ текущего состояния. Описывает две возможности изданий — `sso` и
`siem-export` (обе относятся к изданию **enterprise**), их границы, состав
отправляемого наружу и порядок отката.

Слой изданий (`docs/03_implementation/EDITIONS.md`, `src/ragraf/edition.py`)
отвечает на вопрос «что разрешено». Этот документ отвечает на следующий: **что
именно делает разрешённая возможность**.

---

## 1. Задача на языке проекта

До этапа И7 обе возможности были объявлены в данных слоя изданий
(`src/ragraf/edition_baseline.json`) и отсутствовали в сборке: `edition.decide()`
честно отвечал «возможность объявлена в издании enterprise, но в сборке нет её
операций». Это правильное поведение — но возможность, которую нельзя включить,
остаётся обещанием.

Этап И7 добавляет четыре операции и два модуля:

| Операция фасада | Инструмент MCP | Модуль | Что делает |
|---|---|---|---|
| `op_sso_login` | `ragraf_sso_login` | `src/ragraf/sso.py` | обмен кода авторизации, проверка `id_token`, роли из групп |
| `op_sso_map_roles` | `ragraf_sso_map_roles` | `src/ragraf/sso.py` | отображение групп каталога на роли панели (без сети) |
| `op_siem_export` | `ragraf_siem_export` | `src/ragraf/siem.py` | выгрузка журналов после курсора |
| `op_siem_test` | `ragraf_siem_test` | `src/ragraf/siem.py` | пробная доставка в приёмник |

Гейт изданий ставится на **все** `op_*` фасада и стоит снаружи остальных обёрток
(`facade._install_edition_gate`, `facade.py`), поэтому без действующей лицензии
предприятия операция получает конверт с `error` вида `EditionRefusal: …` и
раздел `refused` — до того, как начнётся работа и даже сверка свежести корпуса.

---

## 2. Единый вход: что именно проверяется

Реализован **OIDC authorization code + PKCE (S256)**. SAML не реализован, и
запрос `sso_provider=saml` получает отказ с причиной: список значений настройки
закрыт (`settings.ENUM_KEYS["sso_provider"] = ("oidc",)`), подобие входа не
выдаётся.

### 2.1 Поток

```
begin()  →  адрес авторизации: response_type=code, client_id, redirect_uri,
            scope, state, nonce, code_challenge(S256)
            + state/nonce/code_verifier → catalog/sso_pending.json (вне корпуса)
   ↓  (браузер владельца у провайдера)
login(code, state)
   ↓  state ищется среди начатых попыток; просроченные отбрасываются
   ↓  POST token_endpoint: grant_type=authorization_code, code,
   ↓                        redirect_uri, client_id, code_verifier
   ↓  id_token → проверка подписи по JWKS → проверка утверждений
   ↓  группы → map_roles() → роли и области панели
   ↓  state удаляется из файла состояния (одноразовость)
```

`login()` возвращает **разбираемый ответ в любом исходе**: `ok`, `stage`,
`reason`. Исключение наружу не выходит — вызывающий отличает «нет сети» от
«токен неверен» по полю `stage`, а не по типу ошибки.

### 2.2 Подпись

* алгоритмы: `RS256/384/512`, `ES256/384/512`, `EdDSA` (закрытый список
  `sso.ALLOWED_ALGS`);
* `alg: none` — отказ;
* `HS256/384/512` — отказ: общий секрет пришлось бы раздать проверяющей стороне,
  и подпись перестала бы доказывать авторство;
* ключ ищется по `kid` в JWKS провайдера; нет ключа с таким `kid` — отказ с
  названным `kid`;
* не сходится ни с одним ключом — отказ с причинами по каждому ключу;
* ключ (`OKP/Ed25519`) проверяется через PyNaCl, `RSA`/`EC` — через
  `cryptography`. Нет библиотеки — отказ `проверка подписи … невозможна: нет
  библиотеки …`, а не «подпись верна».

### 2.3 Утверждения

| Утверждение | Что проверяется | Причина отказа |
|---|---|---|
| `iss` | точное совпадение с `sso_issuer` (без завершающего `/`) | названы оба значения |
| `aud` | содержит `sso_client_id` | назван ожидаемый клиент |
| `azp` | при нескольких `aud` обязан равняться клиенту | названо полученное значение |
| `exp` | обязательное число; `now − skew ≥ exp` — отказ | названы `exp` и текущее время |
| `iat` | не позже текущего времени с допуском | «выпущен в будущем» |
| `nbf` | не позже текущего времени с допуском | «ещё не действует» |
| `nonce` | сравнение постоянного времени с ожидаемым | «относится к другой попытке входа» |
| `sub` | непустое | «субъект не назван» |

Допуск расхождения часов — `sso_clock_skew_seconds` (по умолчанию 60 с).

### 2.4 Роли

```
группы (утверждение sso_role_claim, по умолчанию groups)
   → отображение sso_group_roles («группа:роль»)
   → роль read | write | admin
   → области панели: read → read; write → read+write; admin → read+write+admin
```

Правила, которые нельзя нарушить молча:

* **роль не берётся из клиента.** Утверждения `role`/`roles` в токене
  игнорируются — читается только отображение, заданное администратором.
  Проверено тестом: токен с `role=admin` и группой без прав даёт роль `read`;
* **роль вне закрытого списка не превращается в право.** Запись вида
  `devs:amin` не проходит проверку настройки, а уже сохранённая попадает в
  `invalid_mapping`;
* **сравнение имён групп без учёта регистра** — это записано в ответе полем
  `compare`, потому что `Domain Admins` и `domain admins` приходят из каталогов
  без предупреждения;
* **пустое отображение = прав нет ни у кого.** Ответ называет это причиной.

---

## 3. Выгрузка в SIEM

### 3.1 Что уходит

Источники: журнал доступа панели (`admin_access_log` в control-хранилище
корпуса) и журнал хранилища секретов (`audit` в `catalog/secrets.sqlite`).

Поля журнала доступа: `id`, `ts`, `surface`, `operation`, `principal`, `label`,
`token_id`, `remote`, `result`, `reason`, `duration_ms`.

Поля журнала хранилища: `id`, `ts`, `op`, `name`, `actor`, `actor_kind`, `via`,
`client`, `task`, `result`, `reason`, `row_hash`.

### 3.2 Что НЕ уходит никогда

* **значения секретов.** Таблица `secret` хранилища не читается вовсе — только
  журнал `audit`, где лежит имя записи. Это не «фильтрация значений», а
  отсутствие источника: значения не попадают в набор по устройству;
* **свободный текст `detail`** журнала доступа. В нём может оказаться что
  угодно, а «мы надеемся, что там нет секрета» доказательством не является;
* **тела запросов** — их у журнала доступа нет по устройству;
* **значение доступа к приёмнику.** Оно берётся из переменной окружения, имя
  которой задаёт `siem_token_env`; в настройках хранится имя, и в ответ панели
  попадает только оно.

Проверяется тестом по составу ключей набора: `detail`, `value`, `ciphertext`,
`secret_value` в наборе отсутствуют.

### 3.3 Набор и подпись

```json
{
  "schema": 1, "product": "ragraf", "source": "access",
  "target": "https://siem.example.com/ingest",
  "generated_at": "2026-10-24T20:00:00Z",
  "records": [ { "id": 41, "ts": "…", "surface": "mcp", "operation": "search", … } ],
  "count": 200, "cursor_from": 41, "cursor_to": 240,
  "fields": ["id", "ts", "surface", "operation", …],
  "note": "только метаданные: значения секретов и тела запросов не выгружаются",
  "signature": {
    "state": "signed", "algorithm": "Ed25519",
    "sha256": "…", "signature": "base64", "public_key": "base64", "signed_at": "…"
  }
}
```

Подпись считается по **каноническому** виду набора (`siem.canonical_bytes`:
ключи отсортированы, разделители фиксированы, поле `signature` исключено) тем же
ключом и тем же кодом, что экспорт аудита и SBOM — `signing.sign_digest_bytes`.
Проверка принимающей стороной: `signing.verify_digest_bytes(canonical, signature)`.

Нет PyNaCl или ключа — набор уходит с `signature.state=unavailable` и причиной,
а не «как будто подписан».

### 3.4 Курсор и повторная доставка

Позиция доставки хранится **отдельной** таблицей `siem_delivery` в
control-хранилище корпуса:

```sql
CREATE TABLE IF NOT EXISTS siem_delivery (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    ts REAL NOT NULL,
    target TEXT NOT NULL,          -- приёмник
    source TEXT NOT NULL,          -- access | secrets
    cursor_from INTEGER NOT NULL,
    cursor_to INTEGER NOT NULL,
    records INTEGER NOT NULL,
    digest TEXT NOT NULL,          -- sha256 набора
    signature TEXT NOT NULL,       -- состояние подписи
    result TEXT NOT NULL,          -- ok | failed
    reason TEXT NOT NULL
);
```

Правила:

* курсор источника — `MAX(cursor_to)` по записям с `result='ok'` для этой пары
  «приёмник + источник»;
* курсор сдвигается **только после успешной доставки** (2xx). Сбой приёмника
  оставляет курсор на месте, поэтому записи не теряются;
* повторная выгрузка при неизменном журнале отправляет **ноль** записей —
  повтор не дублирует доставленное;
* проба (`siem_test`) курсор не трогает и журналы не читает.

Миграция **только добавляющая**: существующие таблицы журналов не меняются,
номера ревизий корпуса не затрагиваются.

### 3.5 Транспорт

| Вид | Адрес | Формат |
|---|---|---|
| `http` | `http(s)://…` | `POST` с `Content-Type: application/json`, набор выше |
| `syslog` | `хост:порт` | одна строка `<14>1 ragraf siem - - - {json}` (UDP или TCP) |

Значение доступа к приёмнику, если нужно, берётся из переменной окружения и
уходит заголовком `Authorization: Bearer …`. В журнал и в ответ попадает только
имя переменной.

Пустой адрес, адрес без порта у syslog, неизвестный вид приёмника — **причина
отказа**, а не исключение: `siem.problems()` вызывается и панелью, и операцией,
поэтому ответ один.

---

## 4. Настройки

Блок «внешний доступ» в `src/ragraf/settings.py` (все ключи — свойство
установки, `GLOBAL_ONLY`):

| Ключ | По умолчанию | Смысл |
|---|---|---|
| `sso_enabled` | `false` | вход включён |
| `sso_provider` | `oidc` | закрытый список: только `oidc` |
| `sso_issuer` | — | ожидаемый `iss` |
| `sso_client_id` | — | публичный идентификатор приложения |
| `sso_redirect_uri` | — | адрес возврата |
| `sso_scopes` | `openid profile email groups` | области через пробел |
| `sso_role_claim` | `groups` | имя утверждения групп |
| `sso_group_roles` | `[]` | записи «группа:роль» |
| `sso_clock_skew_seconds` | `60` | допуск часов |
| `sso_http_timeout_seconds` | `10` | предел ожидания провайдера |
| `sso_authorization_endpoint` / `sso_token_endpoint` / `sso_jwks_uri` | — | адреса; пусто — из документа обнаружения |
| `siem_enabled` | `false` | выгрузка включена |
| `siem_target` | `http` | `http` или `syslog` |
| `siem_endpoint` | — | адрес приёмника |
| `siem_protocol` | `udp` | для syslog: `udp` или `tcp` |
| `siem_batch` | `200` | записей в наборе (1–1000) |
| `siem_timeout_seconds` | `5` | предел ожидания приёмника |
| `siem_sign` | `true` | подписывать набор |
| `siem_token_env` | `RAGRAF_SIEM_TOKEN` | имя переменной со значением доступа |

**Файл, названный окружением.** `RAGRAF_EXTERNAL_CONFIG` указывает JSON-файл со
значениями этих ключей (`{"settings": {...}}` или плоский объект). Он **сильнее**
`catalog/settings.json` и читается через `settings.external_overrides()`.
Нужен там, где панель не запущена (закрытый контур), а настройку и проверку
входа надо выполнить. Посторонние ключи в файле отбрасываются: опечатка не
становится настройкой.

---

## 5. Панель управления

Страница `/external-access` (`admin/external-access.html`), ссылка — в
подвале сайдбара панели. API:

| Метод | Путь | Права |
|---|---|---|
| `GET` | `/api/external-access` | без входа: значений доступа в ответе нет |
| `POST` | `/api/external-access` | область **admin**; отказ виден в `admin_access_log` |
| `GET` | `/external-access` | без входа (страница) |

Действия POST: `save` (значения `sso_*`/`siem_*`), `sso_begin`, `map_roles`,
`siem_test`, `siem_export` (`dry_run`).

Проверка `Host`/`Origin` действует на все маршруты: она стоит в
`Handler.parse_request` и срабатывает до разбора пути.

---

## 6. Проверки

```powershell
python tests\test_external_access.py        # 87 свойств
python tests\test_editions.py               # 65 свойств (издание и подпись лицензии)
python tests\test_cli_ops_dispatch.py       # каждая операция OPS проходит диспетчер
pwsh -NoLogo -NoProfile -NonInteractive -File tools\Invoke-ProjectDoctrine.ps1
```

28-я проверка чекера — `Test-ExternalAccess`. Она запускает
`tests/test_external_access.py` и требует не менее 40 осмотренных свойств: ноль
свойств при успехе считается ложным PASS и валит прогон.

Что проверяется поведением (а не наличием файла):

* просроченный токен, чужой `iss`, чужое `aud`, неверный `azp`, отсутствие
  `exp`/`sub`/`nonce`, `nbf` в будущем — отказ с названной причиной;
* подпись чужим ключом, неизвестный `kid`, `alg=none`, `HS256`, пустой JWKS —
  отказ;
* роль берётся из группы: токен с `role=admin` и группой без прав даёт `read`;
* вход целиком: `state` одноразовый, повторное предъявление отвергается,
  недоступная сеть названа причиной, просроченная попытка отвергается;
* выгрузка: причина при пустом приёмнике, неизменность курсора при сбое,
  отсутствие повторов, отсутствие `detail` и значений секретов в наборе,
  проверка подписи набора тем же `signing`;
* издание: на Community — `EditionRefusal` с `requires_edition=enterprise`,
  на enterprise с подписанной лицензией — работа, с подделанной и с подписанной
  чужим ключом — снова отказ.

Командная строка:

```powershell
python tools\ragraf.py edition                       # возможности и раздел missing
python tools\ragraf.py query sso_map_roles --groups "domain admins,developers"
python tools\ragraf.py query sso_login --code … --state …
python tools\ragraf.py query siem_test
python tools\ragraf.py query siem_export --source-kind access --dry-run
```

---

## 7. Откат

Изменения не касаются схемы существующих хранилищ и нумерации ревизий корпуса.

1. Снять проверку: удалить `'Test-ExternalAccess'` из `$script:CheckFunctions`
   в `tools/Invoke-ProjectDoctrine.ps1` и саму функцию.
2. Вернуть данные слоя изданий: в `src/ragraf/edition_baseline.json` возможности
   `sso` и `siem-export` снова не имеют операций в сборке — достаточно удалить
   четыре `op_*` из фасада, и обе возможности вернутся в раздел `missing` с
   причиной «в сборке нет операций». Это проверяется тем же `edition.decide()`.
3. Удалить `src/ragraf/sso.py`, `src/ragraf/siem.py`, `tests/test_external_access.py`,
   `admin/external-access.html`, этот документ; убрать четыре инструмента из
   `src/ragraf/mcp_server.py` (`TOOLS`, `_TOOL_META`, `_handlers`) и четыре
   операции из `src/ragraf/cli.py` (`OPS`, `dispatch_op`, аргументы).
4. Удалить блок `_EXTERNAL_DEFAULTS` и `external_overrides` из
   `src/ragraf/settings.py`, ветки `/api/external-access` и `/external-access` из
   `src/ragraf/admin_server.py`, ссылку из `PAGE_HTML`.
5. Удалить таблицу позиции доставки: `DROP TABLE siem_delivery;` в
   `control.sqlite` нужного корпуса. Журналы (`admin_access_log`, `audit`) не
   затрагивались и удалению не подлежат.
6. `python tools\ragraf.py -p config\ragraf.yaml reindex`.

Сохранение для отката: `.ragraf/backup-i7/` — копии изменённых файлов
после правки и скрипты правок, в которых дословно записан ПРЕЖНИЙ текст
каждого заменённого фрагмента (`patches/`). Копий «до правки» нет, и это
названо прямо: возврат идёт по парам «было → стало» из скриптов либо
шагами 1–6 выше.

---

## 8. Границы, названные прямо

* **SAML не реализован.** Возможность `sso` в данных называет OIDC; запрос
  `provider=saml` получает отказ с причиной.
* **Вход не выдаёт токенов RAGRAF.** Он возвращает роли и области панели;
  именованные токены по-прежнему создаются в разделе доступа панели.
* **Обратный маршрут SIEM отсутствует.** RAGRAF только отправляет; запросов к
  приёмнику, кроме пробной доставки, нет.
* **Содержимое запросов не выгружается** — не по настройке, а потому что
  источника нет.
* **Ключ подписи один на установку** (`catalog/audit-signing.key`), отдельного
  ключа для SIEM нет: две подписи разошлись бы форматом.
