Перейти к содержанию

Policy Service

policy-service — Policy Decision Point (PDP) платформы: отвечает на вопросы «может ли principal P выполнить действие A над ресурсом R» и «какие объекты типа T principal P может читать» с учётом дерева workspace, отношений на ресурсе и делегирования. Движок решений — OpenFGA (ReBAC). Статья описывает модель, развёртывание, API, подключение Control Plane и памяти и порядок перехода с локальных прав на PDP.

Experimental

Профиль compose policy — experimental (TAI-ADR-0040; модель — TAI-ADR-0025, статус Deferred). Он не входит в ядро, не поднимается по умолчанию и не получает новых возможностей. По умолчанию Control Plane решает сам (CP_AUTHZ_MODE=local) по плоским permissions binding'а — этого достаточно для работы платформы.

Зачем нужен PDP

Без policy-service Control Plane проверяет права плоским списком permissions из IAM-binding'а principal'а: право tasks.write действует на все задачи tenant'а. Policy-service добавляет:

Возможность Пример
Права на поддерево workspace роль на workspace действует в нём и во вложенных
Права из отношений на ресурсе владелец задачи может её менять без общего tasks.write
Запреты по отношению решать approval нельзя тому, кто его запросил
Делегирование временная передача роли другому principal'у с expiresAt
Списки видимых объектов list-objects для фильтрации списков и видимости памяти
Объяснение решения explain показывает путь и binding'и, давшие allow

Компоненты

flowchart LR
    CP[control-plane-api] -->|"check / list-objects<br/>(service account, on-behalf)"| PS[policy-service :8030]
    MEM[memory-service] -->|list-objects| PS
    PS --> FGA[OpenFGA :8080]
    PS --> DB[(policy-db: policy)]
    FGA --> DB2[(policy-db: openfga)]
    W[policy-worker] -->|"GET /api/v1/events"| CP
    W -->|"outbox IAM"| IAM[iam-service]
    W --> FGA
    W --> DB
Сервис compose Образ Назначение
policy-db postgres:16-alpine базы policy (сервис) и openfga (движок); вторая создаётся init-скриптом policy-service/deploy/init-openfga.sql
openfga-migrate openfga/openfga:v1.20.0 одноразовый: миграции схемы OpenFGA
openfga openfga/openfga:v1.20.0 движок; только внутренняя сеть, playground выключен
policy-service из policy-service/Dockerfile (контекст — корень суперпроекта) PDP и admin API, порт 8030
policy-worker тот же образ, команда policy-worker проекция журналов Control Plane и IAM в отношения движка

Порт на хосте — 127.0.0.1:${POL_HOST_PORT:-18040}. Наружу policy-service не публикуется: его клиенты — сервисы внутри сети.

Конфигурация

Переменные с префиксом POL_ (значения из compose.yml):

Переменная Значение Назначение
POL_DATABASE_URL postgresql+psycopg://policy:…@policy-db:5432/policy база сервиса
POL_BOOTSTRAP_TOKEN из .env заголовок X-Policy-Bootstrap-Token для admin API
POL_IAM_ISSUER ${TAIMEN_PUBLIC_URL}/iam issuer токенов
POL_IAM_JWKS_URL http://iam-service:8010/.well-known/jwks.json ключи IAM (альтернатива — POL_IAM_PUBLIC_KEY[_FILE])
POL_IAM_AUDIENCE policy-service принимаемый audience
POL_FGA_URL http://openfga:8080 движок
POL_FGA_STORE_PREFIX ${COMPOSE_PROJECT_NAME:-taimen} префикс имён store в OpenFGA
POL_FGA_PRESHARED_KEY — ключ OpenFGA, если движок его требует
POL_FGA_TIMEOUT_SECONDS 3.0 таймаут вызова движка
POL_CONTROL_PLANE_URL http://control-plane-api:8000 источник проекции: журнал Control Plane
POL_CONTROL_PLANE_TOKEN из secrets/policy-service-cp.env Bearer воркера для чтения журнала
POL_IAM_URL, POL_IAM_EVENTS_BOOTSTRAP_TOKEN http://iam-service:8010, IAM_BOOTSTRAP_TOKEN источник проекции: outbox IAM
POL_PROJECTION_POLL_SECONDS 2.0 интервал опроса журналов
POL_PROJECTION_BATCH_SIZE 200 размер пачки событий

Модель авторизации

Каталоги действий

Каждый resource service описывает свои действия и типы ресурсов в файле authz/catalog.yaml своего репозитория (в поставке — у control-plane и memory-service). Пример фрагмента каталога Control Plane:

service: control-plane
version: 1

resource_types:
  task:
    scope: workspace
    relations: [owner, requested_by, assignee, type_role]
  run:
    parent: task
    relations: [holder]
  approval:
    scope: workspace
    relations: [requested_by]

actions:
  tasks.read:
    resource: task
    derived: "owner or requested_by or assignee or tasks.read@scope"
  tasks.write:
    resource: task
    derived: "owner or tasks.write@scope"
  approvals.decide:
    resource: approval
    derived: "approvals.decide@scope but not requested_by"
  runs.control:
    resources:
      task: "runs.control@scope"
      run: "holder or runs.control@task"
Элемент Смысл
scope: workspace ресурс живёт в workspace; X@scope — право X, выданное на этот workspace, его предков или tenant
parent: task ресурс наследует решения родителя (X@task)
relations отношения на ресурсе, которые проецируются из журналов или передаются contextual tuples
derived выражение: отношения, права из scope, or, and, but not

Имена действий Control Plane совпадают с его permissions (tasks.read, approvals.decide, …). Исключения: admin становится системной ролью tenant-admin, управление делегированиями — admin API policy-service.

Из чего складывается решение

flowchart TD
    C[Каталоги сервисов] -->|model-versions:publish| M[Модель OpenFGA, model_version]
    M --> S[Store tenant'а]
    R[Роли: key → actions] --> S
    B[Bindings: subject + role + scope] --> S
    P[Проекция журналов: workspace#parent, task#owner, …] --> S
    CT[Contextual tuples запроса] --> D{check}
    S --> D
    D --> A[allowed + reason_code + decision_id]
Понятие Описание
Store отдельный store OpenFGA на tenant IAM
Роль набор действий; kind: system, tenant или generated (созданные миграцией)
Binding единственный источник grant'ов: субъект (principal или group) получает роль на scope (tenant или workspace), опционально с окном startsAt/expiresAt
Делегирование binding с источником delegation: delegator передаёт роль delegate'у на scope, expiresAt обязателен
Отношения структура (дерево workspace, владелец и исполнитель задачи, держатель run) — проецируется воркером из журналов, не редактируется руками
Contextual tuples отношения, которые вызывающий вычисляет на момент проверки (например, роль типа задачи) и передаёт в запросе

Субъект решения — IAM principal (sub токена), а не локальный id principal'а в Control Plane.

Проекция журналов

policy-worker читает журнал Control Plane (GET /api/v1/events с курсором) и outbox IAM, превращая события в отношения движка:

Событие Control Plane Отношения
workspace.created / перемещение workspace#tenant, workspace#parent
task.created / task.updated task#scope, task#owner, task#assignee, task#requested_by
run.started run#task, run#holder
approval.requested approval#scope, approval#requested_by
artifact.created artifact#task
project.created / updated project_profile#scope, project_profile#owner

Курсоры хранятся в базе сервиса (projection_cursors), состояние видно в GET /api/v1/projection. Проекция консервативна: отношения, которого ещё нет, означает deny, а не allow.

Токен воркера статический

Воркер читает журнал Control Plane значением POL_CONTROL_PLANE_TOKEN, которое bootstrap получает обменом client credentials один раз и записывает в secrets/policy-service-cp.env. Это обычный короткоживущий access token IAM: после истечения его срока проекция из Control Plane останавливается с ошибкой авторизации. Для длительной работы обновляйте токен (обмен POST /iam/api/v1/tokens/exchange по POL_CP_CLIENT_ID / POL_CP_CLIENT_SECRET из того же файла) и пересоздавайте policy-worker.

Коды решения

reasonCode Смысл
allowed разрешено
denied_no_binding нет binding'а или отношения, дающего действие
unknown_action действие не зарегистрировано ни одним каталогом
unknown_resource_type тип ресурса неизвестен
action_not_applicable действие не применимо к этому типу ресурса
tenant_unknown у tenant'а нет store
unavailable движок недоступен

API

Ключи JSON — camelCase. Базовый адрес внутри сети — http://policy-service:8030.

Аутентификация

Группа Кто проходит
decisions (check, batch-check, list-objects, list-subjects, explain) токен IAM audience policy-service со scope policy:check (или policy:admin)
решение за другого principal'а (principalId ≠ sub) дополнительно scope policy:check-on-behalf, иначе 403 principal_not_allowed
admin (каталоги, модель, stores, роли, bindings, делегирования, simulate, access review, проекция, события) заголовок X-Policy-Bootstrap-Token или токен со scope policy:admin своего tenant'а (403 tenant_mismatch для чужого)

Tenant решения всегда берётся из токена. Неверный токен — 401 invalid_iam_context, недоступный IAM — 503 iam_verification_unavailable.

Решения

Метод и путь Что делает
POST /api/v1/decisions:check одно решение
POST /api/v1/decisions:batch-check до 100 решений за вызов
POST /api/v1/decisions:list-objects объекты типа, на которых действие разрешено (до 1000)
POST /api/v1/decisions:list-subjects principals, которым действие на ресурсе разрешено
GET /api/v1/decisions/{decisionId}:explain путь решения и binding'и
curl -s http://policy-service:8030/api/v1/decisions:check \
  -H "Authorization: Bearer $SERVICE_TOKEN" -H 'Content-Type: application/json' \
  -d '{
    "principalId": "<iam-principal-id>",
    "action": "tasks.write",
    "resource": {"type": "task", "id": "<task-id>"},
    "context": {"contextualTuples": [
      {"object": "task:<task-id>", "relation": "scope", "subject": "workspace:<workspace-id>"}
    ]},
    "consistency": "strong"
  }'
{
  "allowed": false,
  "reasonCode": "denied_no_binding",
  "decisionId": "3b0e…",
  "policyVersion": "…",
  "modelVersion": "4",
  "evaluatedAt": "2026-01-15T10:12:03Z",
  "consistencyToken": null,
  "action": "tasks.write",
  "resource": "task:<task-id>"
}

consistency: default (допускается ограниченная устарелость) или strong (для мутаций и привилегированных действий). Каждое решение пишется в append-only decision_log без токенов и payload.

Администрирование

Метод и путь Что делает
POST /api/v1/catalogs/{service} зарегистрировать каталог (тело — YAML, Content-Type: text/yaml); ответ version, changed, actions
POST /api/v1/model-versions:publish собрать модель из каталогов и опубликовать во все stores
POST / GET /api/v1/tenants/{tenantId}/stores создать или прочитать store tenant'а
POST / GET /api/v1/tenants/{tenantId}/roles upsert роли {key, name, kind, actions} / список
POST / GET /api/v1/tenants/{tenantId}/bindings выдать {subject, roleKey, scope, startsAt?, expiresAt?} / список
POST /api/v1/tenants/{tenantId}/bindings/{id}:revoke отозвать {reason?}
POST /api/v1/tenants/{tenantId}/delegations делегировать {delegatorId, delegateId, roleKey, scope, expiresAt}
POST /api/v1/tenants/{tenantId}/decisions:simulate «что если»: решение с гипотетическими extraBindings (до 20)
GET /api/v1/tenants/{tenantId}/access-review обзор выданных доступов
GET /api/v1/projection состояние курсоров проекции
GET /api/v1/events журнал событий сервиса (binding.created/revoked, role.updated, model.published)

Выдача роли на workspace:

curl -s -X POST http://127.0.0.1:18040/api/v1/tenants/$IAM_TENANT_ID/bindings \
  -H "X-Policy-Bootstrap-Token: $POL_BOOTSTRAP_TOKEN" -H 'Content-Type: application/json' \
  -d '{"subject": {"type": "principal", "id": "<iam-principal-id>"},
       "roleKey": "tenant-admin",
       "scope": {"type": "workspace", "id": "<workspace-id>"}}'

Отзыв сначала удаляет отношения в движке, затем помечает строку revoked и пишет событие — удаление никогда не расширяет доступ.

Здоровье

Эндпоинт Ответ
GET /healthz {"ok": true, "version", "modelVersion"}
GET /readyz 200, если движок отвечает и модель загружена; иначе 503 с {"engine", "modelLoaded"}

Подключение Control Plane

Control Plane выбирает источник решений переменной CP_AUTHZ_MODE:

Режим Кто решает Когда policy-service недоступен
local (по умолчанию) плоские permissions binding'а; policy-service не вызывается не важно
shadow решают permissions; PDP спрашивается параллельно, расхождения считаются и пишутся в журнал ответ не меняется, растёт authz_shadow_unavailable_total
policy PDP для всех credential с IAM-субъектом; permissions — только для legacy-ключей без IAM 503 policy_unavailable (fail closed)

Control Plane обращается к PDP своим service account'ом и называет конечного principal'а через on_behalf_of, поэтому service account ядра должен иметь audience policy-service со scopes policy:check и policy:check-on-behalf (bootstrap заводит их по умолчанию, секрет — в secrets/control-plane-iam.env). Без CP_IAM_CLIENT_ID/CP_IAM_CLIENT_SECRET режимы shadow и policy не стартуют — полуготовая конфигурация падает при запуске, а не остаётся молча в local.

Переменная Control Plane По умолчанию Назначение
CP_AUTHZ_MODE local режим
CP_POLICY_BASE_URL в compose http://policy-service:8030 адрес PDP
CP_POLICY_AUDIENCE policy-service audience токена service account'а
CP_POLICY_SCOPES ["policy:check","policy:check-on-behalf"] запрашиваемые scopes
CP_POLICY_TIMEOUT_SECONDS 3.0 таймаут вызова
CP_POLICY_CACHE_TTL_SECONDS 5.0 кэш решений check с обычной согласованностью

В режиме policy дополнительно действует фильтрация списков: Control Plane запрашивает list-objects и показывает только разрешённые объекты. В режимах local и shadow ограничений сверх плоского права нет.

Метрики и журнал режима shadow

Метрика Смысл
authz_shadow_checks_total сколько сравнений выполнено
authz_shadow_divergence_total сколько решений разошлось
authz_shadow_unavailable_total PDP не ответил или отказал в запросе
authz_shadow_skipped_total credential без IAM-субъекта (legacy-ключ)
authz_policy_unavailable_total в режиме policy: PDP недоступен

Каждое расхождение — предупреждение authz shadow divergence в журнале control_plane.authz с полями iamPrincipalId, actions, resource, localAllowed, policyAllowed, reasonCode, decisionId.

Подключение памяти

memory-service может вычислять видимость namespaces и scopes через PDP: MEMORY_POLICY_ENABLED=true (в сервисе — CB_POLICY_ENABLED, адрес CB_POLICY_URL=http://policy-service:8030). Service account памяти с audience policy-service заводит bootstrap (шаг 7) в secrets/memory-service-iam.env. Подробности — в Namespaces и доступ.

Переход на PDP

  1. Поднять профиль policy, выполнить make bootstrap ARGS=--policy (каталоги, модель, store, роль tenant-admin оператору, service accounts).
  2. Перенести плоские permissions в bindings:

    python3 deploy/policy/migrate_bindings.py --env .env --state deploy/state/<инсталляция>.json
    

    Каждый уникальный набор permissions из IAM-binding'ов Control Plane становится ролью generated:<hash>, principal получает её на scope tenant'а; admin → роль tenant-admin. Скрипт идемпотентен. Нужен PAT с control-plane:admin. 3. Переключить Control Plane в CP_AUTHZ_MODE=shadow, пересоздать control-plane-api, control-plane-worker, context-adapter. 4. Наблюдать authz_shadow_divergence_total и журнал расхождений; дизайн рекомендует не меньше недели работы в shadow с нулём расхождений. 5. Переключить в CP_AUTHZ_MODE=policy.

Откат — возврат CP_AUTHZ_MODE=local и пересоздание процессов Control Plane: bindings Control Plane при переходе не удаляются.

См. также