key-cape/docs/operations.md
tegwick 7a73352368 Record the custody owner's confirmations and guard a receipt against misreading
railiance-platform replied on all three open threads. Recording what they add
rather than what we already knew.

T02: they reached the same reading of the verifier receipt independently and add
human_client_consume_denied: true for both clients, confirm the verifier ran
twice (activation and post-rollout) at generation 38 with the signing key
unchanged, and state T02 can close against that receipt. They also correct our
framing, rightly: the packet says client-side retrieval is unadmitted, which
stays true, but the attended operator path is not a client-side read and never
required one. This task had been treating those as the same constraint.

T04: the live approval clients mean this repo now holds a committed receipt that
looks like rotation evidence and is not. Both owners independently state the same
two gaps -- no real predecessor rotation, no observed wall-clock expiry. Recorded
in T04 rather than only in T02, because verify-client's predecessor rejection is
now implemented and unproven, which is a different state from missing or done,
and T04 is where that distinction belongs.

Answered their open question on CCR-2026-0020, which has no named presenting
actor. As issuer: the registration carries both approval:create and
approval:approve, so one presenter can create an entry and approve it. That is a
separation-of-duties property of the holder, not a defect in the token -- KeyCape
issues the grants approval-engine asked for. Recorded both shapes KeyCape can
support, and that who holds it is approval-engine's decision and the doctrine
question gate-house's, not ours.

Also narrowed the operations note: the deployed config has not been written since
the activation, so the inspected state is the state that will boot.

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

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 1182213@bnt-lap001
Assistant-Session: 966597b9-ae61-46a4-8b9e-1594ab3ec4ad
2026-09-09 20:05:40 +02:00

8.2 KiB

Operating KeyCape

The supported deployment topology and the limits that come with it. These are deliberate boundaries of the current implementation, not defects awaiting a fix: where a limit is a consequence of a design choice, the choice is named.

Topology: exactly one replica

Run one instance per issuer. Authorization codes, login sessions and registration/enrollment handoffs are held in process memory. There is no shared store, and no sticky-session configuration makes this safe: a browser that starts a login on one replica and returns from Authelia to another finds no session and must start again. Two replicas do not halve the failure rate, they roughly double the login failure rate.

This is a documented exclusion rather than a missing feature. Adding a shared session store is a real option if the profile ever requires horizontal scale; it is not required today.

Consequences to plan for:

  • A restart drops in-flight logins. Anyone mid-login gets an error and must start again. Issued tokens are unaffected — they are self-contained JWTs and stay valid until they expire.
  • Rolling deployments are single-instance rollovers, so expect a brief window where new logins fail. Draining (below) protects requests already in flight, not logins waiting on a human at Authelia's password prompt.

Liveness and readiness

Endpoint Answers Use it for
/healthz Is the process up? Probes nothing. Liveness.
/readyz Are LLDAP, Authelia and privacyIDEA reachable? Readiness / traffic gating.

Keep these distinct. Wiring liveness to /readyz means an orchestrator restarts KeyCape whenever a dependency blinks, turning someone else's blip into an outage of your own — and a restart also discards every in-flight login, making it worse than the condition it reacted to.

/readyz returns 200 with status: ready, or 503 with status: not_ready and the failing check named. It reports which check failed, never why: the endpoint is unauthenticated and upstream error text carries hostnames and occasionally credentials-in-URLs. The reason is in the server log.

Probes are reachability and credential checks, not functional tests. LLDAP is probed with a bind, so a rotated or revoked service password is caught — that leaves the port open and every lookup failing, which is exactly what readiness should catch. Authelia and privacyIDEA are probed with a plain HTTP GET: any response means something is listening and speaking HTTP.

Results are cached for 2 seconds and each probe is bounded at 3 seconds. The cache is not an optimisation: the endpoint is necessarily unauthenticated, and without it anyone able to reach it could drive one upstream request per dependency per call.

Shutdown

On SIGTERM or SIGINT the server stops accepting connections and gives in-flight requests up to 15 seconds to finish. The grace period is deliberately under the 30-second read/write timeouts, so a stuck request cannot outlive the window an orchestrator typically allows before SIGKILL.

In-memory login and authorization state is not preserved across shutdown, by design — see the topology section.

Key and registration lifecycle

The signing key and all client registrations are read once at startup. Both are changed by editing configuration and restarting; there is no reload signal and no rotation service.

The key ID is the constant key-1. Rotating the signing key while keeping that identifier is a trap: a consumer caching JWKS by kid may keep the old key and reject freshly issued tokens until its cache expires. Plan rotation as "publish new keys, let consumers refetch, then issue with the new key", and treat a same-kid swap as a breaking change for anyone caching aggressively.

Removing a client registration does not revoke tokens already issued to it. There is no introspection or revocation endpoint, so an issued token stays valid until it expires — 15 minutes by default, or the client's tokenLifetime. To cut off a compromised client, remove the registration and wait out the lifetime, or rotate the signing key if you cannot.

/logout clears the local KeyCape session only. It does not end the Authelia session and does not revoke any issued token. A user who logs out and back in may not be prompted for credentials, because the upstream session is still valid.

Transport

The server speaks plain HTTP. TLS termination belongs to the deployment. KeyCape deliberately does not check or gate on the transport used to reach its upstream providers either; upstream ID tokens are verified cryptographically instead, so that assurance does not depend on the network being what we believe it is (KEY-WP-0019).

Upstream issuer pinning (read before rolling out KEY-WP-0019)

KeyCape verifies upstream ID tokens against the issuer the provider advertises, discovered server-side from authelia.tokenBaseURL. Some providers — Authelia among them — derive the advertised issuer from the request Host, so the value KeyCape learns over an in-cluster service address is not the value minted into tokens issued for the browser-facing host.

Where that is true, pin it explicitly:

authelia:
  tokenBaseURL: "http://authelia.sso.svc.cluster.local:9091"
  issuer: "https://auth.coulomb.social"   # exactly the iss claim in ID tokens
  jwksUrl: "http://authelia.sso.svc.cluster.local:9091/jwks.json"

Verification fails closed, so a mismatch means every human login fails — and it looks like a broken login rather than a configuration error. Check the issuer before rolling out, not after. The failure is diagnosable: the authentication failure event carries error_type=id_token_issuer_mismatch, as distinct from id_token_signature or provider_keys_unavailable.

Confirm the value with the Host the provider will actually see:

curl -s -H "Host: auth.coulomb.social" \
  http://authelia.sso.svc.cluster.local:9091/.well-known/openid-configuration \
  | jq -r .issuer

Unreleased fail-closed startup changes (read before the next rollout)

The deployed image at the time of writing is sha256:7ff54c54e63ee172ae9e6e7fd2da96e427352f712343d74626ee6fe0f6f82611, built from dcebd46. Two changes on main postdate it, both affect startup or issuance, and both fail closed, so they land together on the next rollout and a mistake in either presents as a refusal rather than as a warning.

1. Browser clients reject service-identity fields (74b35b6, KEY-WP-0028). Config validation now rejects serviceSubject or roles on a client whose grant is not client_credentials; those fields were silently ignored there, so a registration could look effective and do nothing. This is a startup error: a stray field in the deployed config stops the process from booting rather than degrading. The live sso/keycape-config was checked on 2026-09-09 and none of its three browser clients carries either field, so it passes as deployed. The custody owner confirmed independently that the config was last written during the 2026-09-09 activation (resource version 58747126) and has not been edited since, so the inspected state is the state that will boot — and that they will re-check the browser clients if anything writes that Secret before the window. tenant is deliberately not rejected; see below.

2. A conflicting tenant binding refuses issuance (329e48f, KEY-WP-0013-T05). A client registration may declare a tenant, which is supplied when the directory has placed the user nowhere and enforced when it has. A declared zone that conflicts with a directory assignment refuses the token with 403 and error_type: tenant_binding. No deployed client declares a tenant on a browser grant today, so nothing changes on rollout; the refusal only becomes reachable once the approver client is registered. See the tenant contract.

Neither has been exercised against a running issuer. The honest proof for both is a live boot with the new binary, which belongs to the attended rollout window and not to a session running against production on its own.

What is not claimed

No resource-efficiency or throughput bounds are asserted here. Nothing in this repository benchmarks KeyCape, so any figure would be invention. Measure it in your own deployment before sizing against it.