API действий с лицензией клиента
Management API предоставляет customer-scoped discovery и durable-выпуск из
Subscription, revoke, archive и ротацию ключа для Vendor Console. Каждый route требует
customers.read и licenses.manage; Tenant и actor берутся только из
authenticated context пользователя или service account.
Discovery и выпуск
GET /api/v1/customers/organizations/{organizationId}/license-issue-candidates?offset=0&limit=50
Discovery возвращает только Trialing или Active Subscriptions, привязанные к
точной Published или Retired OfferVersion, принадлежащие path Customer, пока
effectiveIssuable остаётся положительным (min остатка SeatLimit тарифа и
общего пула). Отсутствие SeatLimit или seat pool — fail closed (не unlimited).
Customer должен быть Active. Порядок фиксирован: время создания Subscription по
убыванию, затем Subscription ID по возрастанию. offset по умолчанию равен
0; limit по умолчанию равен 50 и принимает 1..200.
Ответ содержит display-поля Product и тарифа, а также immutable identity Subscription/Offer. В нём нет policy references, key material, hashes, provider references и внутренних codes. Discovery advisory: issue повторно читает всех owners и working-seat remaining.
POST /api/v1/customers/organizations/{organizationId}/license-issues
Idempotency-Key: customer-generated-value
Content-Type: application/json
{
"customerSubscriptionId": "0198f35d-7b00-7000-8000-000000000001",
"correlationId": "0198f35d-7b00-7000-8000-000000000002"
}
Product, Customer, OfferVersion, policy, validity, License code и plaintext key
не принимаются из request. Первый committed issue возвращает 201 и
существующий receipt выпуска из Subscription, включая format-v2 plaintext key.
Точный durable replay возвращает 200, те же references и mask, но
plaintextKey: null. Повторное использование ключа для другого semantic
request даёт conflict. Revoked и Archived Licenses пожизненно занимают исходную
Subscription.
Терминальные действия
POST /api/v1/customers/organizations/{organizationId}/licenses/{licenseId}/revoke
POST /api/v1/customers/organizations/{organizationId}/licenses/{licenseId}/archive
Content-Type: application/json
{
"expectedStatus": "Active",
"reasonCode": "customer_request",
"reasonDetail": "optional bounded safe detail"
}
JSON-объект строгий: разрешены только expectedStatus, reasonCode и
необязательный reasonDetail; неизвестные и повторяющиеся поля отклоняются.
expectedStatus чувствителен к регистру и защищает от stale UI state. Эти
действия не используют Idempotency-Key и не принимают в body Tenant,
Customer, Product, policy, operation или actor identity.
| Действие | Разрешённый исходный status | Итоговый status |
|---|---|---|
| Revoke | Draft, Active или Suspended | Revoked |
| Archive | Draft или Revoked | Archived |
Success возвращает 200 с operationId, идентичным effectId, licenseId,
action, fromStatus, toStatus, completedAtUtc и
isIdempotentReplay. Точный replay возвращает исходный receipt с
isIdempotentReplay: true; изменённая или stale-попытка даёт conflict. Path
Customer должен существовать и точно совпадать с Customer, сохранённым в
License.
Переход License, audit evidence и сообщение integration outbox фиксируются одной durable-транзакцией до ответа. После revoke выполняются только best-effort cache hint/invalidation; archive только инвалидирует cached assists. Сбой cache не отменяет committed action, а PostgreSQL остаётся authoritative.
Существующие глобальные single и bulk revoke routes сохраняют request и
response envelopes. Их legacy-поле body revokedByActorId deprecated,
принимается и игнорируется; всегда используется authenticated Management
actor. Повторный global single revoke по-прежнему даёт HTTP 409, а уже
применённый bulk item остаётся успешным с alreadyApplied: true.
Durable-ротация ключа
POST /api/v1/customers/organizations/{organizationId}/licenses/{licenseId}/key/rotations
Idempotency-Key: customer-generated-value
Content-Type: application/json
{
"expectedPrimaryMaterialRefId": "0198f35d-7b00-7000-8000-000000000001"
}
Строгий body принимает только reference текущего primary material как
stale-write precondition. Header обязан содержать ровно один ограниченный
Idempotency-Key. Authority Tenant, Customer, License и actor никогда не
берётся из body. Для нового effect path License должна принадлежать точному
Active Customer; затем BC-LIC внутри атомарной ротации повторно проверяет state
License, migration state, текущий primary и immutable policy snapshot.
Success возвращает 200 с operationId, licenseId, keyMaterialRefId,
maskedKey, formatVersion, createdAtUtc, plaintextKey и
isIdempotentReplay. Первый полностью успешный вызов может один раз раскрыть
новый v2 plaintext. Точный retry возвращает durable references и mask с
plaintextKey: null и isIdempotentReplay: true; повторное использование
ключа с изменённым содержимым даёт conflict.
Операция BC-LIC фиксируется до идемпотентного append audit-события
license.key.rotated, но HTTP success отправляется только после durable-записи
обоих результатов. Если audit или доставка ответа падает после commit ротации,
повторите идентичные header и body: второй ключ не создаётся, недостающий audit
завершается, а replay всё равно возвращает null plaintext. Изменение status
после уже committed effect не блокирует такой recovery replay. Каждый ответ
handler помечен no-store и no-cache; plaintext никогда не сохраняется, не
попадает в audit и не логируется.
Существующий global route
POST /api/v1/licenses/{licenseId}/key/rotate сохраняет v1 success envelope и
требует только licenses.manage. Reliable mode использует тот же
expected-primary body вместе с Idempotency-Key. Отсутствие обоих включает
deprecated headerless compatibility mode: он остаётся внутренне атомарным, но
не допускает надёжного network retry и возвращает headers Deprecation и
Warning. Передача только header или только body невалидна. Новые интеграции
обязаны использовать reliable mode; Customer route никогда не переходит в
compatibility mode.
Ошибки
Все ошибки используют platform error body и correlation ID.
| HTTP | Code | Значение |
|---|---|---|
| 400 | customer_license_actions.invalid_request | Ошибка path, paging, header или strict body |
| 404 | customer_license_actions.not_found | Отсутствующий, foreign или wrong-customer source |
| 409 | customer_license_actions.customer_not_active | Customer не Active |
| 409 | customer_license_actions.not_issuable | Commercial или policy source больше не разрешает выпуск |
| 409 | customer_license_actions.subscription_occupied | Subscription уже занята License |
| 409 | customer_license_actions.stale_precondition | Owner evidence изменился |
| 409 | customer_license_actions.transition_not_allowed | Terminal transition запрещён из текущего state |
| 409 | customer_license_actions.idempotency_conflict | Ключ повторно использован с другим содержимым |
| 503 | customer_license_actions.dependency_unavailable | Недоступен обязательный owner, audit или infrastructure |
| 500 | customer_license_actions.stored_result_invalid | Durable evidence issue или rotation нарушает контракт |
Ошибки и replay никогда не раскрывают plaintext key material. Существующий
глобальный route POST /api/v1/licenses/subscription-issues остаётся совместим.