Федерация identity¶
IAM не хранит паролей и не показывает форму входа: человека аутентифицирует
внешний OIDC Identity Provider (в поставке — Keycloak), а IAM проверяет его
токен, связывает учётную запись с principal и выпускает credentials
платформы. Статья описывает регистрацию IdP, вход через federation:authenticate
и federation:exchange, привязку identities, проекцию групп и
SCIM-провижининг. Для администраторов, подключающих корпоративный вход.
Как это работает¶
sequenceDiagram
participant U as Браузер
participant KC as Keycloak (IdP)
participant API as platform-api (шлюз)
participant IAM as iam-service
participant CP as control-plane
U->>KC: вход (Authorization Code + PKCE)
KC-->>U: upstream token (aud включает iam-service)
U->>API: запрос к /api/v1/services/control-plane/…
API->>IAM: POST /api/v1/tenants/{t}/federation:exchange<br/>{identityProvider, token, audience: "control-plane"}
IAM->>KC: discovery + JWKS (кэш)
IAM->>IAM: проверка подписи, iss, aud, exp<br/>linking, проекция групп,<br/>authentication context
IAM-->>API: {accessToken aud=control-plane, principalId, groups, …}
API->>CP: Authorization: Bearer <accessToken>
Федерация подтверждает identity и группы. Она не выдаёт лицензий и доменных прав: доступ к ресурсу по-прежнему решает resource service (для Control Plane — binding principal, см. Авторизация и права).
Регистрация identity provider¶
Провайдер регистрируется в tenant административным вызовом. В типовой
инсталляции с Keycloak из профиля platform:
curl -s -X POST "$IAM_URL/api/v1/tenants/$TENANT/identity-providers" \
-H "X-IAM-Bootstrap-Token: $IAM_BOOTSTRAP_TOKEN" -H 'Content-Type: application/json' \
-d '{
"key": "keycloak",
"issuer": "https://platform.example.com/auth/realms/platform",
"audience": "iam-service",
"lifecycleProfile": "managed",
"groupClaim": "groups",
"groupMappings": {"platform-operators": "operators"}
}'
Шаг выполняется вручную
make bootstrap identity provider не регистрирует. Ключ keycloak
совпадает со значением SERVICE_GATEWAY_IDENTITY_PROVIDER, которое шлюз
platform-api передаёт в federation:exchange (в compose.yml — keycloak).
Параметры провайдера¶
| Поле | По умолчанию | Описание |
|---|---|---|
key |
— | Имя провайдера в tenant, ^[a-z0-9][a-z0-9._-]{1,118}[a-z0-9]$; передаётся клиентами как identityProvider |
issuer |
— | Точный iss upstream-токенов; только http(s)://, конечный / отбрасывается |
audience |
— | Какой aud IAM требует в upstream-токене (в Keycloak — клиент iam-service и audience-маппер) |
jwksUri |
"" |
Адрес JWKS. Пусто — берётся из discovery <issuer>/.well-known/openid-configuration |
subjectClaim |
sub |
Claim со стабильным subject |
externalIdClaim |
sub |
Claim со стабильным внешним ID (для LDAP — например entryUUID); пусто в токене → берётся subject |
groupClaim |
groups |
Claim со списком групп |
groupMappings |
{} |
Allowlist: upstream-группа → ключ группы IAM |
requiredAcrValues |
[] |
Допустимые acr; непусто — вход без подходящего acr запрещён |
requiredAmrValues |
[] |
Методы, все из которых должны быть в amr |
lifecycleProfile |
read_only |
Кто ведёт жизненный цикл identities, см. ниже |
jwksCacheTtlSeconds |
300 | Сколько JWKS считается свежим (0–86400) |
jwksStaleGraceSeconds |
900 | Сколько после TTL можно работать на старом JWKS, если IdP недоступен (0–86400) |
В IAM хранится только несекретная конфигурация доверия. Client secrets,
пароли и LDAP bind credentials остаются в конфигурации самого IdP. Лишние
поля в запросе запрещены (422).
Ошибки регистрации: 404 tenant_not_found, 422 invalid_issuer,
409 identity_provider_exists (тот же key или тот же issuer в tenant).
Изменить провайдера через API нельзя
Эндпоинтов чтения, изменения и отключения identity provider нет. Проверьте параметры до регистрации; исправление — только в базе IAM.
Профили жизненного цикла¶
lifecycleProfile |
Кто ведёт identities | Ручная привязка external identity для этого issuer | SCIM-источник |
|---|---|---|---|
read_only |
каталог (LDAP/AD через IdP) | запрещена: 409 identity_provider_managed |
не регистрируется: 409 population_managed_by_directory |
managed |
администратор / SCIM | разрешена | разрешён |
Выбирайте managed, если principals уже существуют в IAM (например, оператор
создан bootstrap-скриптом) и их нужно привязать к учётным записям IdP вручную.
Проверка upstream-токена¶
IAM проверяет upstream-токен строго, без «мягких» режимов:
| Проверка | Отказ |
|---|---|
| Токен разбирается как JWT | 401 invalid_token |
alg ∈ RS256, RS384, RS512, ES256, ES384 (симметричные и none отклоняются до обращения к ключу) |
401 unsupported_algorithm |
Есть kid, и он есть в JWKS провайдера |
401 unknown_signing_key |
| Подпись | 401 invalid_signature |
iss точно равен issuer провайдера |
401 invalid_issuer |
aud содержит audience провайдера |
401 invalid_audience |
exp не истёк; обязательны iss, sub, aud, exp, iat |
401 token_expired / 401 invalid_token |
Claim subjectClaim непуст |
401 missing_subject_claim |
acr/amr удовлетворяют требованиям провайдера |
403 step_up_required |
Недостаточный authentication context закрывает вход, а не понижает требования (step-up).
JWKS провайдера и деградация¶
IAM кеширует JWKS каждого провайдера в памяти процесса:
flowchart LR
R["Запрос входа"] --> F{"кэш моложе<br/>jwksCacheTtlSeconds?"}
F -- да --> OK["проверка на кэше"]
F -- нет --> N["discovery/JWKS у IdP"]
N -- успех --> OK2["обновить кэш,<br/>проверка"]
N -- ошибка --> G{"кэш в пределах<br/>TTL + grace?"}
G -- да --> ST["проверка на старом кэше<br/>identityProviderStale: true"]
G -- нет / кэша нет --> D["503 identity_provider_unavailable"]
- Discovery-документ должен содержать
issuer, точно равный настроенному, и непустойjwks_uri. Таймаут запроса к IdP — 5 с. - При работе на устаревшем кэше ответ содержит
identityProviderStale: true, а в audit добавляется пометкаjwks:stale. - После окна grace вход закрывается (fail closed), а не продолжает доверять старым ключам.
IAM должен видеть IdP по адресу issuer
Discovery идёт по <issuer>/.well-known/openid-configuration, то есть по
публичному адресу. В compose.yml периметр Caddy имеет в сети сервисов
псевдоним ${TAIMEN_PUBLIC_HOST}, поэтому контейнер IAM достигает
Keycloak по публичному имени. Если IAM в вашей топологии не может
разрешить публичное имя, задайте jwksUri явно на внутренний адрес.
Связывание с principal (linking)¶
После проверки токена IAM ищет external identity:
- по паре
(issuer, subject); - по паре
(identity provider, externalId).
flowchart TD
A["upstream claims:<br/>subject, externalId"] --> B{"найдено по subject<br/>и по externalId,<br/>но это разные записи?"}
B -- да --> X1["409 external_identity_conflict"]
B -- нет --> C{"запись найдена?"}
C -- нет --> J["JIT: новый human principal<br/>displayName = key:externalId,<br/>membership, external identity (federated)"]
C -- да --> D{"identity и principal активны,<br/>membership в tenant активен?"}
D -- identity disabled --> X2["403 identity_disabled"]
D -- principal disabled --> X3["403 principal_disabled"]
D -- нет membership --> X4["403 principal_not_in_tenant"]
D -- да --> E["усыновить ручную запись провайдером,<br/>дописать externalId,<br/>обновить subject при смене"]
- JIT-создание. Неизвестная учётная запись создаёт новый principal вида
humanс именем<key провайдера>:<externalId>, membership в tenant и external identity сsource = federated. Публикуются событияprincipal.createdиexternal_identity.linked. - Усыновление. Запись, привязанная вручную (без провайдера), при первом
входе получает ссылку на провайдера (
federation.identity_adoptedв audit). - Смена subject. Если IdP пересоздал пользователя, но стабильный
externalIdтот же, subject записи обновляется (federation.subject_rotatedв audit), второй principal не появляется. - Конфликт. Если
externalIdв токене расходится с уже записанным для этого subject —409 external_identity_conflict.
Principal для существующего человека — заранее
Если человек уже работает в платформе как principal (например, получил
PAT), а затем входит через IdP впервые, без предварительной привязки будет
создан второй principal. Чтобы вход через браузер шёл под тем же
principal_id, привяжите external identity заранее
(POST …/principals/{id}/external-identities, провайдер с профилем
managed) — см. Tenants и principals.
Проекция групп¶
Группы из claim groupClaim проецируются в группы IAM только по
allowlist groupMappings:
- ведущий
/и пробелы в имени upstream-группы игнорируются (/platform-operators=platform-operators); - группа без явного маппинга не создаёт членства;
- отсутствующая группа IAM создаётся с
source = federated; - при каждом входе federated-членства этого провайдера приводятся к текущему токену: лишние удаляются, недостающие добавляются. Локальные членства и членства других провайдеров не затрагиваются.
События: group.created, group_membership.added, group_membership.removed.
Authentication context¶
Каждый успешный федеративный вход в той же транзакции записывает
authentication context человека (source = federation) с issuer, acr,
amr и auth_time из upstream-токена. Он:
- открывает человеку выпуск PAT в течение
IAM_PAT_MAX_AUTHENTICATION_AGE_SECONDS(см. Credentials и PAT); - даёт значения
auth_timeиacrдля токенаfederation:exchange.
auth_time из будущего подрезается до серверного времени.
federation:authenticate — только подтверждение¶
curl -s -X POST "$IAM_URL/api/v1/tenants/$TENANT/federation:authenticate" \
-H 'Content-Type: application/json' \
-d '{"identityProvider":"keycloak","token":"<access token IdP>"}'
{
"principalId": "<principal-id>",
"identityProvider": "keycloak",
"groups": ["operators"],
"authenticationContext": {"acr": "1", "amr": ["pwd"], "authTime": "2026-01-15T10:00:00Z"},
"identityProviderStale": false
}
Credential не выпускается. Эндпоинт не требует bootstrap-токена —
доказательством служит сам upstream-токен. Тело принимает только
identityProvider и token: поля username/password запрещены контрактом,
пароль каталога проверяет исключительно IdP.
federation:exchange — вход и токен одним запросом¶
Для человека в браузере: у шлюза есть только upstream-токен пользователя, PAT через веб-сессию не проходит, а общий сервисный credential в шлюзе потерял бы человека в audit.
curl -s -X POST "$IAM_URL/api/v1/tenants/$TENANT/federation:exchange" \
-H 'Content-Type: application/json' \
-d '{"identityProvider":"keycloak","token":"<access token IdP>",
"audience":"control-plane","scopes":[]}'
{
"accessToken": "eyJ…",
"tokenType": "Bearer",
"expiresIn": 300,
"audience": "control-plane",
"scope": ["control-plane:admin", "control-plane:read", "control-plane:write"],
"sessionId": "<session-id>",
"principalId": "<principal-id>",
"identityProvider": "keycloak",
"groups": ["operators"],
"authenticationContext": {"acr": "1", "amr": ["pwd"], "authTime": "2026-01-15T10:00:00Z"},
"identityProviderStale": false
}
Отличия от обмена PAT:
federation:exchange |
PAT :exchange |
|
|---|---|---|
| Потолок | allowedScopes audience — собственного потолка у веб-входа нет |
scopeCeiling PAT ∩ allowedScopes |
Пустой scopes |
весь allowedScopes audience |
весь потолок |
credential_id в токене |
id external identity: её отключение закрывает следующий обмен | id PAT |
| Кому доступно | только human (422 human_principal_required) |
human, agent |
auth_time, acr |
из текущего upstream-токена | из снимка при выпуске PAT |
Форма токена та же, что при обмене PAT (principal_type, scope_ceiling,
session_id, auth_time, acr) — resource service разницы не видит.
Отказ по audience или scope пишется в audit как federation.exchange с
outcome = denied; сам вход (linking, группы, context) при этом остаётся
зафиксированным.
Права решает сервис, а не scope
Пустой scopes даёт весь реестр audience, включая control-plane:admin.
Это потолок, а не право: что человек реально может, определяет binding
его principal в Control Plane.
Настройка шлюза platform-api (SERVICE_GATEWAY_IAM_URL,
SERVICE_GATEWAY_IAM_TENANT_ID, SERVICE_GATEWAY_IDENTITY_PROVIDER,
SERVICE_GATEWAY_UPSTREAMS) — в статье Шлюз platform-api.
Настройка Keycloak — в статье Вход людей — Keycloak.
Подключение Keycloak: чек-лист¶
- В realm есть клиент
iam-service(bearer-only) и audience-маппер, который добавляетiam-serviceвaudaccess token клиента, через который входят люди. В IAM передаётся именно access token IdP: в поставляемом realm audience-маппер пишетiam-serviceтолько в него, не в ID token. - Issuer realm совпадает с
issuerпровайдера в IAM символ в символ (схема, хост, путь/auth/realms/<realm>). - Провайдер зарегистрирован в IAM (
POST …/identity-providers) с тем жеkey, что использует шлюз. - Для людей, уже существующих как principals, external identities
привязаны заранее (
subject=subпользователя в Keycloak). - Для каждого человека, который будет работать в Control Plane, создан binding его IAM principal в Control Plane до первого запроса.
- Проверка:
federation:authenticateс токеном тестового пользователя возвращает ожидаемыйprincipalIdи группы.
SCIM-провижининг¶
IAM принимает входящий SCIM 2.0 из HR-системы или IGA и проецирует его на principals, external identities и группы. Это не способ входа: SCIM управляет чужим жизненным циклом.
Источник провижининга¶
Каждая population (upstream identity provider) имеет ровно один authoritative источник. Источник регистрируется административно:
curl -s -X POST "$IAM_URL/api/v1/tenants/$TENANT/provisioning-sources" \
-H "X-IAM-Bootstrap-Token: $IAM_BOOTSTRAP_TOKEN" -H 'Content-Type: application/json' \
-d '{"key":"hr","kind":"scim","identityProvider":"keycloak",
"servicePrincipalId":"<principal-id service account>",
"upstreamMode":"off","staleAfterSeconds":86400}'
| Поле | По умолчанию | Описание |
|---|---|---|
key |
— | Имя источника |
kind |
scim |
scim или ldap |
identityProvider |
— | key провайдера-population; для read_only SCIM запрещён |
servicePrincipalId |
— | Principal вида service_account, от имени которого ходит SCIM-клиент (обязателен для scim) |
upstreamMode |
off |
Запись в IdP: off — только проекция IAM; scim — нативный SCIM Keycloak; admin — Admin API; auto — SCIM с откатом на Admin API |
upstreamBaseUrl, upstreamRealm |
"" |
Адрес и realm IdP для записи |
staleAfterSeconds |
86400 | Через сколько без синхронизации источник считается устаревшим (60–2 592 000) |
GET …/provisioning-sources возвращает источники с флагом stale; первое
обнаружение устаревания публикует событие provisioning_source.stale —
по нему удобно строить alert.
Доступ SCIM-клиента¶
SCIM-клиент — service account с audience IAM_SCIM_AUDIENCE (по умолчанию
iam-scim) и scope IAM_SCIM_SCOPE (scim:write). Audience нужно завести в
tenant, service account — создать с этим audience и потолком, а его
principalId указать в источнике.
TOKEN=$(curl -s -X POST "$IAM_URL/api/v1/tokens/exchange" -H 'Content-Type: application/json' \
-d '{"clientId":"iam_sa_…","clientSecret":"…","audience":"iam-scim","scopes":["scim:write"]}' \
| jq -r .accessToken)
curl -s "$IAM_URL/scim/v2/Users?filter=externalId%20eq%20%22E-1001%22" \
-H "Authorization: Bearer $TOKEN"
Токен человека на /scim/v2 не принимается (403): требуется
principal_type = service_account. Tenant и источник определяются по
identity из токена — объявить чужой tenant клиент не может.
Поведение SCIM¶
UsersиGroups:GET(список с фильтром и пагинацией, доIAM_SCIM_MAX_PAGE_SIZE= 200),POST,GET/{id},PUT,PATCH,DELETE;ServiceProviderConfig,ResourceTypes,Schemas.- Сопоставление — по обязательному
externalId;userNameможет меняться. - Профильные атрибуты (
name,emails, телефоны) принимаются и отбрасываются — IAM не каталог персональных данных. - Фильтр поддерживает только
eqиand; прочее —invalidFilter. ETag/If-Matchзащищают от гонки двух проходов синхронизации.active: falseиDELETEотключают principal и немедленно отзывают все его PAT.- Пока настоящего OIDC
subнет, вsubjectлежит временное значение; при первом федеративном входе запись находится поexternalId, иsubjectзаменяется настоящим. - Недоступность записи в IdP при
upstreamMode≠offзакрывает запись целиком (502): расхождение проекции и каталога опаснее отказа. - Ошибки — документ
urn:ietf:params:scim:api:messages:2.0:Error.
Ошибки федерации¶
| HTTP | detail |
Причина |
|---|---|---|
| 401 | invalid_token |
токен не разбирается или не прошёл общую проверку |
| 401 | unsupported_algorithm |
алгоритм вне allowlist |
| 401 | unknown_signing_key |
нет kid или его нет в JWKS |
| 401 | invalid_signature |
подпись не сходится |
| 401 | invalid_issuer |
iss не равен issuer провайдера |
| 401 | invalid_audience |
в aud нет audience провайдера |
| 401 | token_expired |
upstream-токен истёк |
| 401 | missing_subject_claim |
пуст claim subjectClaim |
| 403 | step_up_required |
acr/amr не удовлетворяют требованиям |
| 403 | identity_disabled |
external identity отключена |
| 403 | principal_disabled |
principal не активен |
| 403 | principal_not_in_tenant |
нет активного membership в tenant |
| 403 | audience_not_allowed |
(exchange) audience не зарегистрирован или выключен |
| 403 | scope_not_allowed |
(exchange) scope вне allowedScopes |
| 404 | identity_provider_not_found |
нет активного провайдера с таким key |
| 409 | external_identity_conflict |
subject и externalId указывают на разные записи |
| 422 | human_principal_required |
(exchange) identity привязана к не-человеку |
| 503 | identity_provider_unavailable |
IdP недоступен, окно grace исчерпано |
Отказы пишутся в audit IAM (federation.authenticate / federation.exchange,
outcome = denied, причина — код ошибки); upstream-токен и subject в audit не
попадают.