Перейти к основному содержимому

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 areainternal
Feature groupauthorization
Статусdraft
Документdocs/06-services/identity/api/internal/check-permission.md

Актор и доступ

ПроверкаЗначение
Actor typeExternalSystem (m2m: gateway, сервисы PIN, admin API — ADR-0053)
Auth policy / Permissionsm2m client-credentials токен с audience pin.identity.internal; конечные пользователи endpoint не видят (не публикуется через gateway)
Scope (RBAC)проверяется не для вызывающего сервиса, а для субъекта UserId; scope-иерархия (платформа / организация / группа проектов / проект) — предмет самой проверки
Record-levelidentity отвечает на уровне permission + scope; record-level (владелец/группа записи) enforce-ит вызывающий контекст (владелец данных, см. role.md «Назначение»)
Tenant isolationзапрос содержит явный UserId; identity отвечает про указанного субъекта, tenant-фильтрация по вызывающему не применяется
Auditна allow — не логируется; deny логируется с Reason (расследования доступов)
Trusted boundaryвход доверенный (внутренняя сеть + m2m auth); тем не менее вся валидация форм входа выполняется

HTTP-контракт

ПолеЗначение
MethodPOST
Route/internal/v1/authorize/check-permission
Success status200 (и allow, и deny возвращаются с телом; 4xx/5xx — только ошибки самого вызова)
Correlationсквозной trace через заголовок traceparent (вызывающий пробрасывает свой контекст)

POST выбран из-за batch-тела; сама операция безопасна и данных не меняет.

Входные данные / параметры запроса

CheckPermissionRequest

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
CallerServicem2m claims (client_id)stringДаRequiredлог/метрики
ChecksbodyIReadOnlyList<PermissionCheckItem>Да1..50 элементов

PermissionCheckItem

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
CheckIdbodystringДа<= 64; ключ корреляции ответа
UserIdbodyGuidДа!= Guid.Empty
PermissionCodebodystringДаpattern ^[a-z][a-z-]*\.[a-z][a-z-]*\.[a-z][a-z-]*$ (permission.md INV-P3); неизвестный код → deny (не ошибка)
ResourceOrganizationIdbodyGuid?Неткоордината ресурса
ResourceProjectGroupIdbodyGuid?Неттолько вместе с ResourceOrganizationId
ResourceProjectIdbodyGuid?Неттолько вместе с ResourceOrganizationId; цепочку project→group→org резолвит вызывающий контекст (владелец данных)

Модель ответа CheckPermissionResult

ПолеТипОбязательностьИсточникMaskingОписание
ResultsIReadOnlyList<PermissionCheckResultItem>ДаcomputedПо одному на каждый PermissionCheckItem, в исходном порядке.

PermissionCheckResultItem

ПолеТипОбязательностьИсточникMaskingОписание
CheckIdstringДаechoКорреляция.
IsAllowedboolДаcomputedallow/deny.
Reasonstring (enum PermissionDenyReason)ДаcomputedAllowed / UserNotFound / UserInactive / PermissionUnknown / NoCoveringAssignment — для deny-логов; вызывающий не обязан показывать пользователю.
MatchedAssignmentIdGuid?Нетпервое покрывшее назначениеОтладка/аудит.

Алгоритм

  1. Контекст и доступ. Операцию выполняет доверенный сервис (system-actor) по m2m токену с audience pin.identity.internal; вызывающий сервис берётся из claim client_id (CallerService). Проверяется право не для вызывающего, а для субъекта UserId из тела.
  2. Проверка входа. Размер батча — 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 «Совместимость»).
  3. Что читаем. По уникальным UserId батча берём прекэшированные наборы прав из кэша perm:{userId}; для промахов кэша загружаем: статусы пользователей, их активные и непросроченные назначения ролей (Status = Active, ExpiresAt не наступил), определения ролей с их permissions, и сопоставление PermissionId → Code из каталога прав. Порядок: кэш → пользователи → назначения → роли.
  4. Бизнес-правила и маппинг. Для каждого check определяем IsAllowed/Reason:
    • пользователь не найден → deny UserNotFound;
    • статус пользователя ∉ {Active, Invited} → deny UserInactive (блокировка действует мгновенно, даже при живом access-JWT, см. block-company-user.md шаг 7);
    • код отсутствует в каталоге прав или помечен IsDeprecated без связей → deny PermissionUnknown;
    • покрытие ресурса назначением по иерархии 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-правил нет); при отсутствии такого назначения — deny NoCoveringAssignment; первое покрывшее назначение возвращается в MatchedAssignmentId.
  5. Результат. Возвращаем CheckPermissionResult с массивом Results в исходном порядке (200). Наборы прав, вычисленные при промахах кэша, кладём в perm:{userId} с TTL 5 минут. Deny-результаты логируются с Reason и CallerService. Цель по задержке — p99 < 50 мс при попадании в кэш (см. README identity).

Диаграмма

Побочные эффекты

ТипДетали
Проекции / кэшperm:{userId} — заполнение промахов, TTL 5 мин; инвалидация — по топику pin.identity.access.v1 (внутренний потребитель identity + после локальных мутаций)
Окно eventual-consistency

Ответ строится из прекэшированного набора прав. Отзыв роли, смена scope, блокировка пользователя вступают в силу для этого endpoint не мгновенно, а после инвалидации perm:{userId} по UserAccessChangedKafkaEvent (топик pin.identity.access.v1); при пропущенной инвалидации остаточное окно ограничено сверху TTL 5 мин. Из-за этого возможно кратковременное «ложное allow» на уже отозванное право (окно ≤ TTL) и, наоборот, задержка появления только что выданного права до прогрева кэша. Блокировка пользователя (Status) применяется приоритетно — при промахе кэша статус читается из PostgreSQL и даёт UserInactive немедленно (см. шаг 4). Вызывающие контексты, кэширующие ответ у себя, обязаны не удерживать deny/allow дольше согласованного окна и подписываться на ту же инвалидацию.

Ошибки и failure modes

КодHTTP statusУсловиеСообщениеRetry
ValidationError400Батч > 50 / неверная форма элементаСписок ошибокno retry
UNAUTHORIZED401m2m токен невалиден / чужой audienceпосле переполучения токена
INTERNAL500Инфраструктурный сбой (PostgreSQL)genericretry с backoff; вызывающий обязан fail-closed (deny)
Деградация и fail-closed

Кэш прав недоступен → деградация до чтения из PostgreSQL (медленнее, см. README identity). Identity недоступен целиком → вызывающие обязаны fail-closed: deny + 503 своей операции (не кэшировать deny дольше 5 с).

Acceptance Criteria

  • Given активный пользователь с назначением роли, покрывающим ресурс по scope и содержащим PermissionCode, When m2m-вызов check-permission, Then IsAllowed = true, Reason = Allowed, MatchedAssignmentId = первое покрывшее назначение, ответ 200.
  • Given у пользователя нет покрывающего назначения с этим правом, When check, Then IsAllowed = false, Reason = NoCoveringAssignment (HTTP 200, не 403).
  • Given UserId не существует / статус пользователя ∉ {Active, Invited} / PermissionCode вне каталога прав, When check, Then deny с Reason = UserNotFound / UserInactive / PermissionUnknown соответственно (HTTP 200); неизвестный код — не ошибка (forward-compatible).
  • Given batch из N проверок (1..50), When check, Then Results содержит ровно N элементов в исходном порядке, каждый скоррелирован по CheckId.
  • Given ресурс без координат, When check, Then покрывает только назначение scope Platform (строгий deny by default для платформенных операций); Organization/ProjectGroup/Project покрывают лишь при совпадении соответствующей координаты ресурса.
  • Given батч > 50 / битый UserId / рассогласованные ресурс-координаты, When check, Then ValidationError (400) на весь батч; Given невалидный/чужой m2m-audience, Then UNAUTHORIZED (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; dashboard identity-overview; alert: рост deny UserNotFound (рассинхрон реплик); runbook: инвалидация кэшей авторизации.

Обратные ссылки

Автоссылки: где используется этот документ.

Связанные документы