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¶
- Поднять профиль
policy, выполнитьmake bootstrap ARGS=--policy(каталоги, модель, store, рольtenant-adminоператору, service accounts). -
Перенести плоские permissions в bindings:
Каждый уникальный набор 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 при переходе не удаляются.