approval-engine/docs/keycape-service-registrations.md
tegwick 24ec18fe3f Refuse to invent the human approver client_id and callback URI
key-cape (KEY-WP-0013-T02) asked for two exact strings for the human
approver registration. This engine is a bearer-token resource server: no
redirect endpoint, no authorization-code/PKCE path, and no Ingress or
external origin, so neither string exists here. Record why, name the
missing owner (a browser-facing approver UI outside this repo), and
confirm that the access token — never the ID token — is what this
resource validates.

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-08 14:47:40 +02:00

7.2 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.

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.