POST /internal/v1/authorize/check-permission — Проверка доступа (internal)
Вызывающий сервис не хранит копию ролевой модели: перед защищённым действием он спрашивает у identity,
разрешено ли субъекту (UserId) конкретное право (PermissionCode) на ресурс, и получает allow/deny
по единой формуле покрытия scope — для одной проверки или батчем до 50 штук за вызов.
Назначение
Межсервисная операция авторизации: gateway и прикладные сервисы (crm, chessboard, catalog, news)
спрашивают identity, есть ли у пользователя нужное право (PermissionCode) на ресурс. Identity
отвечает allow/deny, применяя каноническую формулу эффективных прав effectivePermissions(user, resource)
из role.md; та же логика применяется при авторизации всех операций identity.
Вызывающие контексты не дублируют ролевую модель, а зовут этот endpoint или держат реплику-кэш с
инвалидацией по топику pin.identity.access.v1. Batch-вариант принимает до 50 проверок за один вызов
(канбан, списки).
Metadata
| Поле | Значение |
|---|---|
| Сервис/контекст | identity |
| API area | internal |
| Feature group | authorization |
| Статус | draft |
| Документ | docs/06-services/identity/api/internal/check-permission.md |
Актор и доступ
| Проверка | Значение |
|---|---|
| Actor type | ExternalSystem (m2m: gateway, сервисы PIN, admin API — ADR-0053) |
| Auth policy / Permissions | m2m client-credentials токен с audience pin.identity.internal; конечные пользователи endpoint не видят (не публикуется через gateway) |
| Scope (RBAC) | проверяется не для вызывающего сервиса, а для субъекта UserId; scope-иерархия (платформа / организация / группа проектов / проект) — предмет самой проверки |
| Record-level | identity отвечает на уровне permission + scope; record-level (владелец/группа записи) enforce-ит вызывающий контекст (владелец данных, см. role.md «Назначение») |
| Tenant isolation | запрос содержит явный UserId; identity отвечает про указанного субъекта, tenant-фильтрация по вызывающему не применяется |
| Audit | на allow — не логируется; deny логируется с Reason (расследования доступов) |
| Trusted boundary | вход доверенный (внутренняя сеть + m2m auth); тем не менее вся валидация форм входа выполняется |
HTTP-контракт
| Поле | Значение |
|---|---|
| Method | POST |
| Route | /internal/v1/authorize/check-permission |
| Success status | 200 (и allow, и deny возвращаются с телом; 4xx/5xx — только ошибки самого вызова) |
| Correlation | сквозной trace через заголовок traceparent (вызывающий пробрасывает свой контекст) |
POST выбран из-за batch-тела; сама операция безопасна и данных не меняет.
Входные данные / параметры запроса
CheckPermissionRequest
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|---|---|---|---|---|
CallerService | m2m claims (client_id) | string | Да | Required | лог/метрики |
Checks | body | IReadOnlyList<PermissionCheckItem> | Да | 1..50 элементов | — |
PermissionCheckItem
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|---|---|---|---|---|
CheckId | body | string | Да | <= 64; ключ корреляции ответа | — |
UserId | body | Guid | Да | != Guid.Empty | — |
PermissionCode | body | string | Да | pattern ^[a-z][a-z-]*\.[a-z][a-z-]*\.[a-z][a-z-]*$ (permission.md INV-P3); неизвестный код → deny (не ошибка) | — |
ResourceOrganizationId | body | Guid? | Нет | координата ресурса | — |
ResourceProjectGroupId | body | Guid? | Нет | только вместе с ResourceOrganizationId | — |
ResourceProjectId | body | Guid? | Нет | только вместе с ResourceOrganizationId; цепочку project→group→org резолвит вызывающий контекст (владелец данных) | — |
Модель ответа CheckPermissionResult
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|---|---|---|---|---|
Results | IReadOnlyList<PermissionCheckResultItem> | Да | computed | — | По одному на каждый PermissionCheckItem, в исходном порядке. |
PermissionCheckResultItem
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|---|---|---|---|---|
CheckId | string | Да | echo | — | Корреляция. |
IsAllowed | bool | Да | computed | — | allow/deny. |
Reason | string (enum PermissionDenyReason) | Да | computed | — | Allowed / UserNotFound / UserInactive / PermissionUnknown / NoCoveringAssignment — для deny-логов; вызывающий не обязан показывать пользователю. |
MatchedAssignmentId | Guid? | Нет | первое покрывшее назначение | — | Отладка/аудит. |
Алгоритм
- Контекст и доступ. Операцию выполняет доверенный сервис (system-actor) по m2m токену с audience
pin.identity.internal; вызывающий сервис берётся из claimclient_id(CallerService). Проверяется право не для вызывающего, а для субъектаUserIdиз тела. - Проверка входа. Размер батча —
1..50; форма каждого элемента:UserId != Guid.Empty,CheckIdне длиннее 64,PermissionCodeсоответствует паттерну (permission.md INV-P3), ресурс-координаты согласованы (ResourceProjectGroupId/ResourceProjectId— только вместе сResourceOrganizationId). Любое нарушение формы — отказValidationError(400 на весь батч). Существование пользователей и кодов прав не считается ошибкой: несуществующий пользователь или неизвестный код даютIsAllowed = falseс соответствующимReason(deny by default, permission.md «Совместимость»). - Что читаем. По уникальным
UserIdбатча берём прекэшированные наборы прав из кэшаperm:{userId}; для промахов кэша загружаем: статусы пользователей, их активные и непросроченные назначения ролей (Status = Active,ExpiresAtне наступил), определения ролей с их permissions, и сопоставлениеPermissionId → Codeиз каталога прав. Порядок: кэш → пользователи → назначения → роли. - Бизнес-правила и маппинг. Для каждого
checkопределяемIsAllowed/Reason:- пользователь не найден → deny
UserNotFound; - статус пользователя ∉ {
Active,Invited} → denyUserInactive(блокировка действует мгновенно, даже при живом access-JWT, см. block-company-user.md шаг 7); - код отсутствует в каталоге прав или помечен
IsDeprecatedбез связей → denyPermissionUnknown; - покрытие ресурса назначением по иерархии scope (role.md):
Platform— покрывает всегда;Organization(o)— еслиresource.OrganizationId == o;ProjectGroup(g)— еслиresource.ProjectGroupId == g(или project-элемент, объявленный вызывающим в группеg);Project(p)— еслиresource.ProjectId == p; ресурс без координат покрывается толькоPlatform(строгий deny by default для платформенных операций); - allow ⇔ существует покрывающее назначение, чья роль содержит
PermissionCode(union-семантика, deny-правил нет); при отсутствии такого назначения — denyNoCoveringAssignment; первое покрывшее назначение возвращается вMatchedAssignmentId.
- пользователь не найден → deny
- Результат. Возвращаем
CheckPermissionResultс массивомResultsв исходном порядке (200). Наборы прав, вычисленные при промахах кэша, кладём вperm:{userId}с TTL 5 минут. Deny-результаты логируются сReasonиCallerService. Цель по задержке — p99 < 50 мс при попадании в кэш (см. README identity).
Диаграмма
Побочные эффекты
| Тип | Детали |
|---|---|
| Проекции / кэш | perm:{userId} — заполнение промахов, TTL 5 мин; инвалидация — по топику pin.identity.access.v1 (внутренний потребитель identity + после локальных мутаций) |
Ответ строится из прекэшированного набора прав. Отзыв роли, смена scope, блокировка пользователя вступают
в силу для этого endpoint не мгновенно, а после инвалидации perm:{userId} по UserAccessChangedKafkaEvent
(топик pin.identity.access.v1); при пропущенной инвалидации остаточное окно ограничено сверху TTL 5 мин.
Из-за этого возможно кратковременное «ложное allow» на уже отозванное право (окно ≤ TTL) и, наоборот,
задержка появления только что выданного права до прогрева кэша. Блокировка пользователя (Status)
применяется приоритетно — при промахе кэша статус читается из PostgreSQL и даёт UserInactive немедленно
(см. шаг 4). Вызывающие контексты, кэширующие ответ у себя, обязаны не удерживать deny/allow дольше
согласованного окна и подписываться на ту же инвалидацию.
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|---|---|---|---|
ValidationError | 400 | Батч > 50 / неверная форма элемента | Список ошибок | no retry |
UNAUTHORIZED | 401 | m2m токен невалиден / чужой audience | — | после переполучения токена |
INTERNAL | 500 | Инфраструктурный сбой (PostgreSQL) | generic | retry с backoff; вызывающий обязан fail-closed (deny) |
Кэш прав недоступен → деградация до чтения из PostgreSQL (медленнее, см. README identity). Identity
недоступен целиком → вызывающие обязаны fail-closed: deny + 503 своей операции (не кэшировать deny
дольше 5 с).
Acceptance Criteria
- Given активный пользователь с назначением роли, покрывающим ресурс по scope и содержащим
PermissionCode, When m2m-вызов check-permission, ThenIsAllowed = true,Reason = Allowed,MatchedAssignmentId= первое покрывшее назначение, ответ200. - Given у пользователя нет покрывающего назначения с этим правом, When check, Then
IsAllowed = false,Reason = NoCoveringAssignment(HTTP200, не403). - Given
UserIdне существует / статус пользователя ∉ {Active,Invited} /PermissionCodeвне каталога прав, When check, Then deny сReason=UserNotFound/UserInactive/PermissionUnknownсоответственно (HTTP200); неизвестный код — не ошибка (forward-compatible). - Given batch из N проверок (
1..50), When check, ThenResultsсодержит ровно N элементов в исходном порядке, каждый скоррелирован поCheckId. - Given ресурс без координат, When check, Then покрывает только назначение scope
Platform(строгий deny by default для платформенных операций);Organization/ProjectGroup/Projectпокрывают лишь при совпадении соответствующей координаты ресурса. - Given батч > 50 / битый
UserId/ рассогласованные ресурс-координаты, When check, ThenValidationError(400) на весь батч; Given невалидный/чужой m2m-audience, ThenUNAUTHORIZED(401). - Given право у пользователя только что отозвано, но
perm:{userId}ещё не инвалидирован, When check, Then возможно «ложное allow» в окне ≤ TTL 5 мин; послеUserAccessChangedKafkaEventследующая проверка даёт корректный deny. - Given сбой PostgreSQL при промахе кэша, When check, Then
INTERNAL(500) с retry по backoff; вызывающий обязан fail-closed (deny своей операции).
Совместимость и наблюдаемость
- Контракт v1; добавление полей ресурса (
ResourceDealIdи т.п.) — additive. НеизвестныйPermissionCode— deny, не ошибка (forward-compatible к новым правам). - Logs: deny с
Reason/CallerService/PermissionCode(UserId— без PII, это Guid). Metrics:identity_check_permission_total{caller, result, reason}, p99 latency (SLO < 50 мс), cache hit ratio. Traces: OTel; dashboardidentity-overview; alert: рост denyUserNotFound(рассинхрон реплик); runbook: инвалидация кэшей авторизации.
Обратные ссылки
Автоссылки: где используется этот документ.
- API (8): GET /api/v1/companies/my — Просмотр своей компании, GET /api/v1/users/{userId}/effective-permissions — Эффективные permissions пользователя, Identity Internal API — реестр методов и общие правила области, POST /api/v1/auth/refresh — Обновление пары токенов (refresh-flow), POST /api/v1/auth/sign-out — Выход (отзыв refresh-сессии), POST /api/v1/companies/my/users/{userId}/block — Блокировка пользователя компании, POST /api/v1/me/active-agency — Выбор активного агентства, POST /api/v1/role-assignments — Назначение роли пользователю в scope
- Use Cases (1): UC-IDN-008. Проверка доступа сервисом (internal introspection эффективных прав)
- registries (1): Реестр API-методов PIN
Связанные документы
- Формула и иерархия scope: domain/role.md; каталог прав: domain/permission.md.
- Человеческий вариант интроспекции (self/чужой с permission): get-user-effective-permissions.md.
- Событие инвалидации кэшей: role.md «События» (
UserAccessChangedKafkaEvent, топикpin.identity.access.v1). - Use case: UC-IDN-008-internal-permission-check.md.
- Решения: ADR-0052 tenancy, ADR-0053 admin/internal-разделение, ADR-0056 simplicity-first (кэш прав).