approval-engine/docs/keycape-service-registrations.md
tegwick 2fdb01d42a Disable the live operator client, keep its custody
key-cape reports the withdrawal came too late: approval-engine-operator
was provisioned in the 2026-09-09 custody window and is wired into a pod.
A live credential that can create and approve approvals, presented by
nobody, is a worse resting state than either extreme.

As requesting owner: disable, do not delete. CCR-2026-0018 stands so it
is restorable under the narrowing constraint once a presenter exists. A
recorded expiry was rejected — it promises to act later on a capability
that is dangerous now, guarding exactly the case where nobody is
watching.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HyybaE7DUXrWYrhbnESCTe

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 1275879@bnt-lap001
Assistant-Session: eb464208-f821-41b2-bc5a-a6c33d92a8ad
2026-09-10 20:49:36 +02:00

12 KiB

Requested KeyCape registrations

Status: requested by APPROVAL-WP-0002-T01. Non-secret. KeyCape owns issuance, client disablement, and the exact claim contract. This file is a consumer request, not a live registration.

Tokens presented to approval-engine MUST use resource-server audience approval-engine. Do not reuse the OpenBao service-auth pattern that sets aud to the OAuth clientId.

Required claims remain those in docs/caller-authentication.md: iss, sub, aud, exp, iat, principal_type, tenant, roles, scope, assurance. principal_type for consume callers must be service or agent.

Resource server

Field Value
Audience approval-engine
Issuer the deployed KeyCape issuer (manifest uses https://kc.coulomb.social)
JWKS GET /jwks on the KeyCape service
Scopes approval:create, approval:read, approval:approve, approval:revoke, approval:supersede, approval:consume, approval:observe, approval:emit

Tenant — resolved: exact tenant:platform

The operator accepted tenant:platform as the platform management, administration and services tenant (the landlord zone). Decision 5ed3fb35-eca9-413a-82b9-95171ba85bf6, recorded in glas-harness/docs/platform-tenant-decision.md and relayed by glas-harness 2026-09-06.

The spelling is the contract. ApiApplication.identity compares the verified JWT tenant claim to the engine's configured store tenant with exact string equality and raises Forbidden before any object lookup (approval_engine/api.py:66). There is no mapping table, no normalisation, and no prefix handling anywhere in this engine — deliberately. platform is not an accepted alias for tenant:platform, and neither is tenant:coulomb.

Rendered values, all three now exactly tenant:platform:

Value Where it is set Content
Store tenant deploy/approval-engine.yaml --tenant tenant:platform
Store tenant default approval_engine/cli.py --tenant, Engine(tenant=…) tenant:platform
JWT tenant claim the client registrations below tenant:platform
CheckRequest tenant flex-auth policy subject tenant:platform

The CLI and Engine defaults were moved off platform in the same change. A default that differs from the sanctioned value is a trap: a serve invocation that omits --tenant would have come up healthy and then refused every authenticated call, which is the failure this decision exists to prevent.

The flex-auth CheckRequest tenant now matches by spelling, but note it still does not participate in this comparison — it is a PDP policy subject this engine never reads. Alignment there is a property of the decision, not a mechanism here.

Denial evidence:

  • tests/test_auth.py::test_wrong_tenant_is_forbidden — a signature-valid token whose tenant differs from the store tenant is refused 403 without mutation.
  • tests/test_auth.py::test_near_miss_tenant_spellings_are_forbidden — pins the decision's "no alias" clause directly: platform, tenant:coulomb, TENANT:PLATFORM, tenant:platform (trailing space) and `` (empty) are each refused against a tenant:platform store, while the exact spelling is admitted. Without a test that varies the tenant, a suite of fixtures all carrying the sanctioned value proves nothing about the field.

This resolves the choice of value only. Verification and the remaining admission gates — KeyCape actually owning these registrations, credential materialization, and the T03 rollout evidence — are unchanged and still open.

Tracked against APPROVAL-WP-0002-T01; related KEY-WP-0013-T02, SECRETS-WP-0009-T03, GLAS-WP-0015.

Clients

Confidential client secrets stay in OpenBao/operator custody. secretRef names below are placeholders for that custody path.

clients:
  - clientId: secrets-engine-approval
    displayName: secrets-engine PEP consume client
    audience: approval-engine
    allowedScopes: [approval:read, approval:consume]
    grantTypes: [client_credentials]
    clientType: confidential
    secretRef: env:KEYCAPE_SECRETS_ENGINE_APPROVAL_CLIENT_SECRET
    serviceSubject: service:secrets-engine
    principal_type: service
    tenant: tenant:platform
    roles: [secrets-engine]
    tokenLifetime: 15m

  - clientId: approval-engine-operator
    displayName: approval-engine lifecycle operator
    audience: approval-engine
    allowedScopes:
      - approval:create
      - approval:read
      - approval:approve
      - approval:revoke
      - approval:supersede
      - approval:observe
      - approval:emit
    grantTypes: [client_credentials]
    clientType: confidential
    secretRef: env:KEYCAPE_APPROVAL_ENGINE_OPERATOR_CLIENT_SECRET
    serviceSubject: service:approval-engine-operator
    principal_type: service
    tenant: tenant:platform
    roles: [approval-operator]
    tokenLifetime: 15m

Human approvers use the existing KeyCape human flow with approval:approve only, still with aud=approval-engine. They must not receive approval:consume. As of 2026-09-09 that flow is informed-decision's (INFD-WP-0001); see docs/approver-surface-requirements.md.

approval-engine-operator — requested, unpresented, do not provision

Answering railiance-platform (RPF-WP-0035-T06, CCR-2026-0020) 2026-09-09. Their question was the right one and we could not answer it from this source either, because the answer is nobody.

Nothing in this repository presents this client, and nothing is deployed that could. This engine only verifies bearer tokens; it never obtains one. The sole outbound credential it holds is the mounted audit-token used for audit-core delivery (approval_engine/audit.py), which is not an OAuth client. approval_engine/pep.py is a consumer-side helper for PEPs such as secrets-engine, which present their own tokens. There is no client-credentials grant anywhere in the package, and deploy/approval-engine.yaml is unapplied.

Scope by scope, the bundle we originally requested does not describe one actor:

Scope Who would present it
approval:create whoever requests an approval. Not named anywhere in the estate today; gate-house doctrine says which acts need one, but no requester identity has been settled
approval:approve informed-decision's human client, per its own registration. A service principal holding this is exactly the question gate-house has not ruled on — see the principal_type note below
approval:read anything rendering or inspecting an approval
approval:revoke, approval:supersede attended lifecycle operations
approval:observe monitoring and operations
approval:emit redundant in normal operation — the running server emits its own heartbeat on --heartbeat-seconds with no token; the route exists only to force one

So this was a convenience bundle written when this repo assumed a single "lifecycle operator" would exist, and it aged badly: it now carries the widest scope set in the pair, including approval:approve, for an identity nobody has named.

Decision, option (b): cancel CCR-2026-0020. No client-side reader is wanted, because there is no presenter. This is an owner decision and not a deferral — recorded here so the request does not rot in_flight. Verifier custody (CCR-2026-0018) is unaffected and remains correct: KeyCape must be able to authenticate this client if it is ever presented.

When a presenter is actually named, the registration should be re-requested narrowed to that presenter's scopes, not reinstated as this bundle. In particular approval:approve should not travel with the operational scopes: schema v4 records entries.principal_type so a service bind is legible, but legibility is not permission, and whether a non-human may supply approver evidence at all is gate-house's to rule on.

secrets-engine-approval above is unaffected — it has a real presenter, a narrow scope pair, and is the client that unblocks T05.

It was already live — disable the registration, keep the custody

2026-09-10: key-cape reports the withdrawal came too late. The client was provisioned in the 2026-09-09 attended custody window and verified there (docs/evidence/2026-09-09-keycape-verifier-admission.json records exact_claims_verified and a working exchange for approval-engine-operator), its secret is in the namespace and wired into a pod.

So the estate holds a live confidential credential that can create and approve approvals, that nobody presents, and that no consumer is waiting on. Standing capability with no counterparty is a worse resting state than either extreme: it serves no one, and it is the shape found later by someone other than its owner.

Decision, as requesting owner: disable the registration, keep the custody. Not delete. CCR-2026-0018 verifier custody stands, so the client remains authenticable and restorable the moment a presenter is named; the re-request constraint above still applies — narrowed to that presenter, and approval:approve must not travel with the operational scopes. key-cape executes the disablement with railiance-platform as the deployment change.

The middle option — leaving it standing with a recorded expiry — was rejected deliberately. An expiry is a promise to act later on a capability that is dangerous now, and the thing it protects against is precisely the case where nobody is watching.

Human approver client — not supplied (2026-09-08)

KEY-WP-0013-T02 asked for two exact strings for the human approver registration (public client, authorization code + S256 PKCE, exact redirect, aud=approval-engine, allowedScopes: [openid, approval:approve], mfaRequired: true): the client_id and the full callback URI.

This repository cannot supply either, and must not guess one. Redirects are matched exactly at /authorize, so a fabricated value fails closed — or, worse, registers a redirect no deployed component owns.

Why there is nothing to give:

  • approval-engine is a bearer-token resource server only. approval_engine/api.py is a WSGI app whose every non-health route reads Authorization: Bearer … (approval_engine/auth.py). There is no /callback, no redirect handling, no authorization-code or PKCE code anywhere in the package.
  • Nothing here is a browser client. The requested registrations above are both client_credentials confidential service clients.
  • The service is not publicly addressable: deploy/approval-engine.yaml has a Namespace, ClusterIP Service and StatefulSet — no Ingress, no external hostname — so no https://…/callback origin exists to register.

The human approver flow therefore belongs to whichever browser-facing approver UI presents approval:approve tokens to this engine. That component is not in this repo and is not named in APPROVAL-WP-0002. The two strings must come from its owner once it exists and has a deployed origin; approval-engine only needs the resulting access token to carry aud=approval-engine, principal_type: human, tenant: tenant:platform and scope approval:approve.

Token confirmed: access token, never the ID token

ApiApplication.identity verifies the access token presented as Authorization: Bearer at this resource — RS256 against the deployed issuer's /jwks, checking iss, aud=approval-engine, exp/iat (30s leeway), sub, principal_type, tenant (exact equality with the store tenant), roles, scope and assurance. id_token appears nowhere in the codebase; the ID token belongs to the login client and is never evidence here. Human principals are additionally refused approval:consume.