key-cape/SCOPE.md

213 lines
16 KiB
Markdown
Raw Normal View History

# SCOPE
Reviewed 2026-09-09 against source revision `7a73352`. The preceding review was
Reassess SCOPE against the current revision SCOPE.md declared itself reviewed against b989de4, 63 commits behind, with its limits kept current by appending to bullets. That works until the header asserts a review date that no longer describes what was reviewed -- the drift this file exists to prevent, in the document whose only job is to be accurate. Every claim was rechecked against source rather than carried forward: routes from the mux registrations, subcommands from the dispatch, flags from the flag sets, claim behaviour from token.go and userinfo.go. Three were false. Caller commands omitted verify-client, the container was said to package only keycape (untrue since KEY-WP-0026), and the issuer API row omitted /readyz. Capabilities that had reached SCOPE only piecemeal are now in the capability table: the authorization-code bindings, upstream token verification, readiness and drain, the human tenant rules, authentication-time assurance, the shared verifier. The substantive addition is a distinction SCOPE did not draw: source behaviour and deployed behaviour are different claims with different evidence -- the test suite and harness runs for one, committed receipts in docs/evidence/ for the other. What is proven live is narrower and now stated exactly, including that the issuer pin's pre-pin state did not match, so that risk was real rather than hypothetical, and that the deployed image predates this revision so several described behaviours are running nowhere yet. That distinction is the fix for a repeated error: four times in two days a claim about current state was written from a workplan paragraph or a partial view instead of the authoritative artifact. A SCOPE that does not separate "the code does this" from "this is running" invites the same mistake from the next reader. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NV9oijZukGyGbRQGGKnK4P Assistant: claude-code Assistant-Model: opus Assistant-Process: 713576@bnt-lap001 Assistant-Session: 384c511d-9bce-4cb8-a676-2aef6c0c8df6
2026-09-09 20:06:38 +02:00
2026-09-05 against `b989de4`; the [reassessment](history/2026-09-09-scope-reassessment.md)
records what changed and how each claim below was checked.
The capability review was performed against `5ae80d3`. `7a73352` landed a minute
before it and changes documentation and workplans only, no source; its substance
reached this document as a peer report and was afterwards confirmed by reading
the commit, which is why the baseline names it. That distinction — read versus
told — is worth keeping in a header whose whole purpose is to let a reader ask
"what changed since?" and get a usable answer.
## Purpose and boundary
KeyCape is Go identity **Tooling** for NetKingdom's lightweight IAM deployment.
It runs its own OIDC-style issuer, uses Authelia for upstream browser
authentication, reads identities from LLDAP, and delegates factor checks to
privacyIDEA. It also provides caller authentication commands, directory snapshot
validation, and migration artifact generators.
It issues identity, role, scope and assurance claims. Resource authorization
belongs to access-engine and consuming services; secret custody and OpenBao
policy/token enforcement belong to their platform owners. The implemented subset
supports NetKingdom integrations, but complete profile conformance and drop-in
Keycloak interchangeability are not established.
## Implemented capabilities
| Surface | What the current source provides |
| --- | --- |
Reassess SCOPE against the current revision SCOPE.md declared itself reviewed against b989de4, 63 commits behind, with its limits kept current by appending to bullets. That works until the header asserts a review date that no longer describes what was reviewed -- the drift this file exists to prevent, in the document whose only job is to be accurate. Every claim was rechecked against source rather than carried forward: routes from the mux registrations, subcommands from the dispatch, flags from the flag sets, claim behaviour from token.go and userinfo.go. Three were false. Caller commands omitted verify-client, the container was said to package only keycape (untrue since KEY-WP-0026), and the issuer API row omitted /readyz. Capabilities that had reached SCOPE only piecemeal are now in the capability table: the authorization-code bindings, upstream token verification, readiness and drain, the human tenant rules, authentication-time assurance, the shared verifier. The substantive addition is a distinction SCOPE did not draw: source behaviour and deployed behaviour are different claims with different evidence -- the test suite and harness runs for one, committed receipts in docs/evidence/ for the other. What is proven live is narrower and now stated exactly, including that the issuer pin's pre-pin state did not match, so that risk was real rather than hypothetical, and that the deployed image predates this revision so several described behaviours are running nowhere yet. That distinction is the fix for a repeated error: four times in two days a claim about current state was written from a workplan paragraph or a partial view instead of the authoritative artifact. A SCOPE that does not separate "the code does this" from "this is running" invites the same mistake from the next reader. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NV9oijZukGyGbRQGGKnK4P Assistant: claude-code Assistant-Model: opus Assistant-Process: 713576@bnt-lap001 Assistant-Session: 384c511d-9bce-4cb8-a676-2aef6c0c8df6
2026-09-09 20:06:38 +02:00
| Issuer HTTP API | Discovery, `/authorize` and its callback/registration-return routes, `/token`, `/jwks`, `/userinfo`, local `/logout`, `/healthz` (liveness, probes nothing) and `/readyz` (probes LLDAP by bind, Authelia and privacyIDEA). |
| Human authentication | Authorization code with S256 PKCE, exact registered redirects, scope allow-lists, Authelia login, privacyIDEA MFA challenges, client-specific assurance requirements, browser session reuse, step-up/fresh-login handling, and configured registration/enrollment handoffs. The exchange is bound to the redirect URI the code was issued for, refused for clients not registered for the grant, authenticated for confidential clients, and the code is consumed atomically so exactly one exchange can succeed. Upstream Authelia ID tokens are verified — signature against the provider's published keys, expected issuer, own client ID in the audience, validity window — failing closed and independently of transport. |
| Service authentication | Static confidential `client_credentials` clients authenticated with form-encoded `client_secret_basic`; configured subject, tenant, roles, scopes and per-client token lifetime. Secrets are resolved from environment references at startup. |
Reassess SCOPE against the current revision SCOPE.md declared itself reviewed against b989de4, 63 commits behind, with its limits kept current by appending to bullets. That works until the header asserts a review date that no longer describes what was reviewed -- the drift this file exists to prevent, in the document whose only job is to be accurate. Every claim was rechecked against source rather than carried forward: routes from the mux registrations, subcommands from the dispatch, flags from the flag sets, claim behaviour from token.go and userinfo.go. Three were false. Caller commands omitted verify-client, the container was said to package only keycape (untrue since KEY-WP-0026), and the issuer API row omitted /readyz. Capabilities that had reached SCOPE only piecemeal are now in the capability table: the authorization-code bindings, upstream token verification, readiness and drain, the human tenant rules, authentication-time assurance, the shared verifier. The substantive addition is a distinction SCOPE did not draw: source behaviour and deployed behaviour are different claims with different evidence -- the test suite and harness runs for one, committed receipts in docs/evidence/ for the other. What is proven live is narrower and now stated exactly, including that the issuer pin's pre-pin state did not match, so that risk was real rather than hypothetical, and that the deployed image predates this revision so several described behaviours are running nowhere yet. That distinction is the fix for a repeated error: four times in two days a claim about current state was written from a workplan paragraph or a partial view instead of the authoritative artifact. A SCOPE that does not separate "the code does this" from "this is running" invites the same mistake from the next reader. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NV9oijZukGyGbRQGGKnK4P Assistant: claude-code Assistant-Model: opus Assistant-Process: 713576@bnt-lap001 Assistant-Session: 384c511d-9bce-4cb8-a676-2aef6c0c8df6
2026-09-09 20:06:38 +02:00
| Tokens and identity | Locally signed RS256 JWTs; configurable access-token resource audience while ID tokens retain the client audience; human/service principal types, tenant, groups, roles, scope and assurance claims. A service tenant is bound at registration; a human tenant comes from the directory, or from a client-declared zone when the directory has placed the user nowhere, and issuance is refused when the two conflict. `assurance.at` is the authentication time, not the mint time. UserInfo verifies algorithm, issuer and access-token purpose, resolves canonical directory subjects, and filters profile/email/groups by scope. |
| Caller commands | `keycape login` for public-client browser PKCE login, `keycape service-token` for service exchange, and `keycape verify-client` to check a live registration's subject, tenant, roles, lifetime and scope refusals without emitting values. HTTPS discovery/JWKS verification and private JSON token-file delivery outside Git; no token output on stdout. |
Make the LLDAP export report its own completeness The exporter discovered groups by walking each user's memberships, so a group nobody belongs to never reached the snapshot, and a failed lookup was skipped by a `continue` under a comment claiming it was recorded in the incompatibility report. The run then emitted `result: "success"`. Add an optional `domain.GroupLister` capability and implement `ListGroups` on the LLDAP adapter as a direct group-subtree search, kept off `UserRepository` because the OIDC layer never enumerates the directory. Record `groupEnumeration` on every result and a `Complete()` predicate over it; abort rather than write a smaller snapshot when the enumeration fails; report a failed per-user lookup on the fallback path; emit `partial` telemetry and name the mode from the CLI. Reading the adapter to write this surfaced a defect the assessment had not listed: `LookupGroups` never populated `Group.Members`, and the exporter built every membership from that field, so against a real directory the `memberships` block was always empty while the fixture-backed tests passed. Memberships on the fallback path now come from the user/group pair actually observed. Sort users, groups and memberships so an unchanged directory exports identically. Closes G05 of the scope/intent assessment. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012WAsfsfQmDu4vcBhiMcmQp Assistant: claude-code Assistant-Model: opus Assistant-Process: 867844@bnt-lap001 Assistant-Session: 3d45905e-0016-4b49-b828-231406881f7b
2026-09-07 08:45:50 +02:00
| Validation and migration | Canonical snapshot checks; deterministic LLDAP user/group/membership export that records whether it enumerated the whole directory; basic Keycloak realm JSON; LDIF generation for OpenLDAP, 389 Directory Server and AD targets. These generate artifacts rather than execute a complete migration. |
Reconcile packaging, bootstrap and migration credential handling Closes gap G09: five loosely related defects. lldap-export took the service account password on argv, where ps exposes it to any local user and shell history and process accounting capture it. It now prefers KEYCAPE_LLDAP_BIND_PW or --bind-pw-file; --bind-pw still works but warns, deprecated rather than removed because existing runbooks use it and breaking them silently would be worse than one more cycle of exposure. Conflicting sources are rejected instead of silently ranked, since an operator otherwise cannot tell which bind was attempted. Both migration scripts pass the password by environment now. The canonical export, generated LDIF and Keycloak realm were written 0644. None carries credential material, but the snapshot is every username, display name, email and group membership in the estate, and it tends to land in /tmp. All three are 0600. The image packaged keycape alone, so the validator and migration binaries needed a Go toolchain on the host -- which defeats shipping an image for the cutover work they exist to support. All five ship; the issuer stays the entrypoint. Verified by building the image and running each binary inside it. The publish workflow named 92.205.130.254:32166 while the cluster runs forgejo.coulomb.social/coulomb/key-cape. It now defaults to the recorded name and stays overridable by a repository variable. This repository cannot verify that the runner resolves that hostname or that the registry credentials are valid for it; if the next publish fails, set the REGISTRY variable back to the address. docker-compose.dev.yml mounts a private key and Authelia material that are correctly absent from the checkout. scripts/bootstrap-dev.sh generates them locally under a restrictive umask rather than chmodding afterwards, so the key is never briefly world-readable. Everything it writes is git-ignored. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NV9oijZukGyGbRQGGKnK4P Assistant: claude-code Assistant-Model: opus Assistant-Process: 713576@bnt-lap001 Assistant-Session: 384c511d-9bce-4cb8-a676-2aef6c0c8df6
2026-09-08 09:51:59 +02:00
| Diagnostics and packaging | Structured authentication/enforcement/migration events, liveness and dependency-probing readiness endpoints, Go build/test/vet targets, a container packaging the issuer and all four migration/validation binaries, and development/CI scaffolding. |
## Material limits
- Client registrations, service secrets and the signing key are loaded at startup.
Login, authorization and handoff state are process-local. There is no shared
session store, general refresh-token flow, token introspection/revocation API,
or automatic signing-key/client-secret rotation service. Logout clears the
local KeyCape session, not every upstream or downstream session/token.
- The optional tenant-engine `tenant_roles` adapter is wired through a
`tenantEngine` config block and is off unless `baseURL` is set (KEY-WP-0024).
When enabled it fails open: an unreachable source omits the claim rather than
failing issuance, so `tenant_roles` is a cache and must not be trusted for
privileged decisions.
Make the LLDAP export report its own completeness The exporter discovered groups by walking each user's memberships, so a group nobody belongs to never reached the snapshot, and a failed lookup was skipped by a `continue` under a comment claiming it was recorded in the incompatibility report. The run then emitted `result: "success"`. Add an optional `domain.GroupLister` capability and implement `ListGroups` on the LLDAP adapter as a direct group-subtree search, kept off `UserRepository` because the OIDC layer never enumerates the directory. Record `groupEnumeration` on every result and a `Complete()` predicate over it; abort rather than write a smaller snapshot when the enumeration fails; report a failed per-user lookup on the fallback path; emit `partial` telemetry and name the mode from the CLI. Reading the adapter to write this surfaced a defect the assessment had not listed: `LookupGroups` never populated `Group.Members`, and the exporter built every membership from that field, so against a real directory the `memberships` block was always empty while the fixture-backed tests passed. Memberships on the fallback path now come from the user/group pair actually observed. Sort users, groups and memberships so an unchanged directory exports identically. Closes G05 of the scope/intent assessment. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012WAsfsfQmDu4vcBhiMcmQp Assistant: claude-code Assistant-Model: opus Assistant-Process: 867844@bnt-lap001 Assistant-Session: 3d45905e-0016-4b49-b828-231406881f7b
2026-09-07 08:45:50 +02:00
- The LLDAP export enumerates the group subtree directly, so groups with no
members are present, and every snapshot carries a `groupEnumeration` field
saying whether that enumeration ran or the membership-derived fallback did
(KEY-WP-0018). A failed enumeration aborts the export rather than writing a
smaller snapshot, and read failures are reported rather than skipped. Users,
groups and memberships are sorted, so an unchanged directory exports
identically. Completeness beyond users, groups and memberships — passwords and
MFA credentials in particular — is still not covered.
Make the Keycloak transform preserve or name every policy field keycape-to-keycloak called Transform, which passes no clients, so it wrote a realm with an empty clients array and nothing said the service-identity contract had not been migrated. Where clients were supplied, mapClient hardcoded standardFlowEnabled — silently giving every client_credentials registration the browser flow — and dropped audience, service subject, tenant, roles, lifetime, MFA policy, secret reference and handoff URLs. Realm roles and client scopes were emitted as empty containers. The defect was not the missing mapping but that a dropped field and an inapplicable one looked identical in the output. Add -clients, reading registrations through a new config.Registrations() that converts without resolving secrets, so migration tooling cannot load material it has no business holding. Derive flows from the declared grants. Carry the profile claims as protocol mappers, since Keycloak has no native concept for them, and lifetime, handoff URLs and the secret reference as attributes — the reference, never a value. Derive realm roles and client scopes from what is present. Report what cannot be carried, in UnpreservedReport, kept deliberately separate from ValidationReport: consistency with the snapshot and completeness of the migration are different questions and one list cannot answer both. It names the unmigrated secret, the unenforceable MFA policy, passwords and factor enrolment, and subject continuity. An incomplete transform emits partial telemetry. Closes the semantic-preservation half of G03; proof against a live provider is G04 and stays open. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012WAsfsfQmDu4vcBhiMcmQp Assistant: claude-code Assistant-Model: opus Assistant-Process: 867844@bnt-lap001 Assistant-Session: 3d45905e-0016-4b49-b828-231406881f7b
2026-09-07 13:48:48 +02:00
- The Keycloak CLI takes a `-clients` config and migrates client registrations:
flows follow the declared grants, and audience, tenant, service subject and
roles are carried as protocol mappers with per-client lifetime, handoff URLs
and the secret *reference* as attributes (KEY-WP-0020). What it cannot carry it
names, in a report separate from the consistency check: secret values, MFA
policy enforcement, passwords and factor enrolment, and subject continuity —
Keycloak mints its own `sub`, so the LLDAP canonical ID survives only as an
attribute. The output is a reviewed artifact, not a proven migration; that
proof needs a live provider swap and is not established.
- Snapshot validation is a limited Go rule set, not full machine-readable schema
enforcement. Raw LDAP attribute keys are checked for descriptor validity,
canonical-mapping shadowing and case-only duplicates (KEY-WP-0021); attribute
values are not validated against a directory schema, and no allow-list of
permitted attribute names exists, since that field carries what the canonical
model does not name. The canonical YAML model and discovery metadata now match the
2026-09-07 00:22:49 +02:00
runtime client-registration surface and are held there by a conformance check
(KEY-WP-0017); the Go model is the runtime authority and the YAML the reviewed
contract. That is narrower than schema enforcement in general.
- The authorization-code grant now binds the redirect URI,
Harden the authorization-code grant and UserInfo verification Closes the local protocol surface of gap G01 from the scope assessment (KEY-WP-0016). The browser grant validated PKCE, client id and scopes but left four bindings unenforced, and UserInfo verified less than the caller CLI does. Authorization-code path: bind the exchange to the redirect URI the code was issued for, refuse clients whose registration does not permit the grant, and authenticate confidential clients with a digest-based constant-time comparison over the same credential sources as the service grant. An empty grantTypes stays an implicit authorization-code client, matching config validation. Code consumption: SessionStore.Consume reads and deletes under one lock. The previous Get/Delete pair spanned JWT signing, and the added test reproduces the race against that version -- 9 of 16 concurrent exchanges succeeded, and a failed exchange left the code replayable. UserInfo: check the JOSE header algorithm before trusting the signature, require the configured issuer, and require an access token rather than accepting an ID token of the right shape. Purpose is decided on the scope claim so the issued token contract, which consumers pin exactly, does not change. SCOPE.md and the assessment record which bindings are now enforced and that the Authelia upstream-trust assumption remains open, so G01 is not fully closed and no profile-conformance claim is made. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NV9oijZukGyGbRQGGKnK4P Assistant: claude-code Assistant-Model: opus Assistant-Process: 713576@bnt-lap001 Assistant-Session: 384c511d-9bce-4cb8-a676-2aef6c0c8df6
2026-09-06 22:43:47 +02:00
enforces grant-type eligibility, authenticates confidential clients and
consumes codes atomically, and UserInfo enforces algorithm, issuer and
Verify upstream Authelia ID tokens Closes the remaining half of gap G01. The adapter decoded upstream ID-token claims without verifying anything, justified in a comment by a server-to-server TLS boundary that nothing enforced. Operator decision: verify the token rather than police the transport. The hop is to be HTTPS as defence in depth, but KeyCape does not monitor, check or gate on that -- a transport check helps only when it is configured correctly, which is the assumption it was meant to remove. Verification holds regardless of how the token arrived, so no HTTPS validation or opt-in flag is added. HandleCallback now verifies the RS256 signature against Authelia's published keys, the issuer Authelia advertises, KeyCape's own client ID in the audience, and a sane validity window, before any claim is trusted. It fails closed: an unreachable or unparseable key set denies the login. The advertised jwks_uri path is rebased onto the server-side token base URL so split-horizon deployments resolve, with config overrides where that inference is wrong, and an unknown key id triggers one refresh so provider rotation needs no restart. The reusable half lives in internal/jose rather than being copied from authclient's verifier, since duplicated verification is how two copies drift and one misses a fix. Migrating authclient onto it is tracked as KEY-WP-0019-T05, kept separate so it does not destabilise a tested path in this change. Thirteen rejection cases plus algorithm and rotation coverage; with the unverified parse restored all fifteen fail, so they test the fix rather than merely passing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NV9oijZukGyGbRQGGKnK4P Assistant: claude-code Assistant-Model: opus Assistant-Process: 713576@bnt-lap001 Assistant-Session: 384c511d-9bce-4cb8-a676-2aef6c0c8df6
2026-09-07 08:51:42 +02:00
access-token purpose (KEY-WP-0016). Upstream Authelia ID tokens are verified
before any claim is trusted — signature against the provider's published keys,
advertised issuer, own client ID in the audience, validity window — failing
closed and independently of transport (KEY-WP-0019). KeyCape deliberately does
not check or gate on the upstream transport. Enforcing these bindings
is not complete profile conformance. Relying parties must repeat `redirect_uri`
on the token exchange and present the access token, not the ID token, to
`/userinfo`; see [authorization-code bindings](docs/authorization-code-bindings.md).
See the assessment below.
- Tests cover local handlers, adapters, transformations and CLI protocol behavior.
They do not establish complete replacement against a running Keycloak/full-LDAP
Make the replacement harness runnable and target a live issuer Closes the runnable half of gap G04. The Scenario B and C scripts could not execute: absent compose files, binaries sought at src/bin where the Makefile builds to bin/, --base-dn passed to a generator whose flag is --basedn, and a hardcoded workstation Go path invoked from outside the module. Repairing the shell alone would have proved nothing. Both scripts set KEYCAPE_TEST_ISSUER while the profile suite built its own httptest server and never read it, so they passed identically whether or not a provider was running. A harness that cannot fail for the reason it exists is worse than a missing one. src/tests/conformance targets the issuer named by KEYCAPE_TEST_ISSUER over HTTP: discovery, the profile authorization surface, published keys parsed under the runtime's own rules, excluded grants, and -- with credentials -- a real token exchange verified against those keys. It skips when the variable is unset, so make test is unchanged. Run against Keycloak 26.0 rather than asserted to work. Discovery, authorization surface and key checks passed, and a client_credentials exchange produced a token that verified against Keycloak's published JWKS through internal/jose. It also failed, correctly: stock Keycloak advertises the excluded implicit and password grants, and in Keycloak those are server capabilities rather than per-client toggles, so no emitted realm removes them. A migrated Keycloak has a wider grant surface than KeyCape, which substantiates with evidence what SCOPE previously asserted without it. Scenario B legitimately reports failure today. Directory migration, credential and MFA preservation and relying-party behaviour remain unexercised, and Scenario C has never been run end to end, so G04 does not fully close. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NV9oijZukGyGbRQGGKnK4P Assistant: claude-code Assistant-Model: opus Assistant-Process: 713576@bnt-lap001 Assistant-Session: 384c511d-9bce-4cb8-a676-2aef6c0c8df6
2026-09-07 23:32:11 +02:00
stack. The Scenario B/C harnesses now run, and `src/tests/conformance` targets a
live issuer named by `KEYCAPE_TEST_ISSUER`, skipping when it is unset
(KEY-WP-0022). Run against Keycloak 26.0 it verified discovery, the
authorization surface, the published keys and a real token exchange — and
showed that stock Keycloak advertises the excluded `implicit` and `password`
Prove the migration against live directories and fix what that surfaced Finishes the unproven half of gap G04. Running the harness against real LLDAP, OpenLDAP and Keycloak found four defects that the full unit suite passed over. Every LLDAP user search pointed at a branch that does not exist. Config.userOU() defaulted to ou=users while LLDAP stores users under ou=people, and nothing set UserOU. LookupUser, ListUsers and ValidatePassword all derive from it, so all three silently found nothing against a stock LLDAP -- human login included, not only the export. Exposed by an export returning zero users while still emitting a membership referencing uid=admin, a snapshot the repo's own validator rejects. raw_attributes_well_formed, added one workplan earlier, rejected the LLDAP adapter's own _validation_warning annotation, so the exporter's output failed its own validation. Tooling annotations are exempt now, and a test proves the exemption does not weaken the rule. Migrated group memberships were dangling: resolveMemberDN passed a source DN through unchanged while entries were written to the target branch, so groups named entries the migrated directory does not contain. And empty groups could not load at all, since groupOfNames makes member a MUST -- they reference a placeholder entry the LDIF creates, an organizationalRole rather than a person, emitted only when some group needs it. The scenario-c compose file could not start: bitnami/openldap:2.6 does not exist, though it passed docker compose config. Pinned to the image the scenario was proved against. Proof: LLDAP -> export -> validate -> LDIF -> ldapadd into OpenLDAP 1.5.0, every entry added and every member resolving; a realm from the same export imports into Keycloak and serves discovery. KeyCape passes 5/5 conformance checks, a migrated Keycloak 4/5. Relying-party behaviour and MFA against a migrated realm remain unexercised, and credential/MFA migration is not supplied at all, so no harness can establish it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NV9oijZukGyGbRQGGKnK4P Assistant: claude-code Assistant-Model: opus Assistant-Process: 713576@bnt-lap001 Assistant-Session: 384c511d-9bce-4cb8-a676-2aef6c0c8df6
2026-09-08 00:29:34 +02:00
grants, so it is not a drop-in for this profile. Directory migration is proved
end to end into a real OpenLDAP, with every migrated membership resolving, and a
realm built from a live export imports into Keycloak and serves discovery
(KEY-WP-0023). Relying-party behaviour and MFA against a migrated realm remain
unexercised, and credential/MFA migration is not supplied at all, so no harness
can establish it. Subject continuity is explicitly not preserved: the canonical
ID survives as an attribute while Keycloak mints its own `sub`.
Reassess SCOPE against the current revision SCOPE.md declared itself reviewed against b989de4, 63 commits behind, with its limits kept current by appending to bullets. That works until the header asserts a review date that no longer describes what was reviewed -- the drift this file exists to prevent, in the document whose only job is to be accurate. Every claim was rechecked against source rather than carried forward: routes from the mux registrations, subcommands from the dispatch, flags from the flag sets, claim behaviour from token.go and userinfo.go. Three were false. Caller commands omitted verify-client, the container was said to package only keycape (untrue since KEY-WP-0026), and the issuer API row omitted /readyz. Capabilities that had reached SCOPE only piecemeal are now in the capability table: the authorization-code bindings, upstream token verification, readiness and drain, the human tenant rules, authentication-time assurance, the shared verifier. The substantive addition is a distinction SCOPE did not draw: source behaviour and deployed behaviour are different claims with different evidence -- the test suite and harness runs for one, committed receipts in docs/evidence/ for the other. What is proven live is narrower and now stated exactly, including that the issuer pin's pre-pin state did not match, so that risk was real rather than hypothetical, and that the deployed image predates this revision so several described behaviours are running nowhere yet. That distinction is the fix for a repeated error: four times in two days a claim about current state was written from a workplan paragraph or a partial view instead of the authoritative artifact. A SCOPE that does not separate "the code does this" from "this is running" invites the same mistake from the next reader. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NV9oijZukGyGbRQGGKnK4P Assistant: claude-code Assistant-Model: opus Assistant-Process: 713576@bnt-lap001 Assistant-Session: 384c511d-9bce-4cb8-a676-2aef6c0c8df6
2026-09-09 20:06:38 +02:00
- **Source behaviour and deployed behaviour are not the same claim, and this
document describes source.** Everything above is what the code in this revision
does, established by 390 Go tests across 23 packages plus the harness runs
named. What has been demonstrated against the live deployment is narrower and is
recorded as committed receipts in `docs/evidence/`: the upstream issuer was
probed and then pinned in `sso/keycape-config`
(`2026-09-09-upstream-issuer-pin.json` — the pre-pin state did **not** match, so
this was a real fix), and both approval service clients were verified live in an
attended owner process, showing JWKS signature verification, exact claims,
excess-scope and wrong-secret denial at a 900s lifetime
(`2026-09-09-keycape-verifier-admission.json`), independently confirmed by the
custody owner. That is live evidence for those two clients specifically —
more than a source claim, less than a general one. On the custody boundary:
client-side retrieval of those secrets is unadmitted and stays so, but the
attended operator path is not a client-side read and never required one;
conflating the two is what left this work recorded as waiting on a run that had
already happened. Those receipts also decline what they did not test: no real
predecessor rotation and no observed wall-clock expiry. Those name a **third
state** this document otherwise has no words for — *implemented and unproven*,
distinct from both missing and done. `verify-client`'s predecessor rejection is
written and unit-tested but has never run against a genuinely distinct
predecessor, so this repository holds a receipt that reads like rotation
evidence and is not. Both owners state that limit independently. Read "there is
a test" and "it has been exercised against the real thing" as separate claims
wherever this document makes one. The deployed image is older than this revision, so the
Reassess SCOPE against the current revision SCOPE.md declared itself reviewed against b989de4, 63 commits behind, with its limits kept current by appending to bullets. That works until the header asserts a review date that no longer describes what was reviewed -- the drift this file exists to prevent, in the document whose only job is to be accurate. Every claim was rechecked against source rather than carried forward: routes from the mux registrations, subcommands from the dispatch, flags from the flag sets, claim behaviour from token.go and userinfo.go. Three were false. Caller commands omitted verify-client, the container was said to package only keycape (untrue since KEY-WP-0026), and the issuer API row omitted /readyz. Capabilities that had reached SCOPE only piecemeal are now in the capability table: the authorization-code bindings, upstream token verification, readiness and drain, the human tenant rules, authentication-time assurance, the shared verifier. The substantive addition is a distinction SCOPE did not draw: source behaviour and deployed behaviour are different claims with different evidence -- the test suite and harness runs for one, committed receipts in docs/evidence/ for the other. What is proven live is narrower and now stated exactly, including that the issuer pin's pre-pin state did not match, so that risk was real rather than hypothetical, and that the deployed image predates this revision so several described behaviours are running nowhere yet. That distinction is the fix for a repeated error: four times in two days a claim about current state was written from a workplan paragraph or a partial view instead of the authoritative artifact. A SCOPE that does not separate "the code does this" from "this is running" invites the same mistake from the next reader. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NV9oijZukGyGbRQGGKnK4P Assistant: claude-code Assistant-Model: opus Assistant-Process: 713576@bnt-lap001 Assistant-Session: 384c511d-9bce-4cb8-a676-2aef6c0c8df6
2026-09-09 20:06:38 +02:00
authorization-code bindings, upstream verification, readiness, graceful
shutdown and the two tenant-related changes are **not yet running anywhere**
see [operations](docs/operations.md), "Unreleased fail-closed startup changes".
Read `docs/evidence/` before describing any of this as proven live.
- The server listens on HTTP; HTTPS termination is deployment-owned.
Give the runtime real readiness, graceful shutdown and stated limits Closes gap G08. /healthz returned a constant without probing anything, the server called ListenAndServe with no signal handling, and the operational limits of in-memory state, startup-loaded keys and local-only logout lived in code comments rather than anywhere an operator would look. /readyz probes LLDAP, Authelia and privacyIDEA; /healthz stays liveness and probes nothing. Keeping them distinct matters: wiring liveness to dependency health means an orchestrator restarts KeyCape when a dependency blinks, and a restart also discards every in-flight login, so the reaction is worse than the condition it reacts to. LLDAP is probed with a bind rather than a dial, since a rotated or revoked service password leaves the port open and every lookup failing -- exactly what readiness should catch and exactly what a dial would miss. The response names the failing check but never the reason: the endpoint is unauthenticated and upstream error text carries hostnames and sometimes credentials-in-URLs. Results are cached for 2s so an unauthenticated endpoint cannot be used to drive unbounded upstream traffic, and probes run concurrently under a 3s bound so a hung dependency makes the endpoint answer rather than hang with it. SIGTERM and SIGINT now drain in-flight requests for 15s, under the 30s read/write timeouts so a stuck request cannot outlive the window before SIGKILL. docs/operations.md states the single-replica topology and why, and three limits easy to get wrong: the constant key-1 key ID makes same-kid rotation a trap for consumers caching JWKS, removing a client does not revoke its issued tokens, and /logout is local only. No throughput figures are given, since nothing here benchmarks KeyCape. Shared storage and refresh tokens stay excluded, as G08 allows. Verified in the running executable: 503 naming all three checks failed while /healthz returned 200, the LLDAP check flipping to ok once started, and 40/40 requests succeeding across a SIGTERM. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NV9oijZukGyGbRQGGKnK4P Assistant: claude-code Assistant-Model: opus Assistant-Process: 713576@bnt-lap001 Assistant-Session: 384c511d-9bce-4cb8-a676-2aef6c0c8df6
2026-09-08 09:43:49 +02:00
`/healthz` is liveness and probes nothing; `/readyz` probes LLDAP, Authelia and
privacyIDEA and gates traffic. `SIGTERM` drains in-flight requests for 15s.
The supported topology is a single replica, since login and authorization state
is process-local — see [operations](docs/operations.md) for that and for the
key-rotation, client-removal and logout limits. Development
Reconcile packaging, bootstrap and migration credential handling Closes gap G09: five loosely related defects. lldap-export took the service account password on argv, where ps exposes it to any local user and shell history and process accounting capture it. It now prefers KEYCAPE_LLDAP_BIND_PW or --bind-pw-file; --bind-pw still works but warns, deprecated rather than removed because existing runbooks use it and breaking them silently would be worse than one more cycle of exposure. Conflicting sources are rejected instead of silently ranked, since an operator otherwise cannot tell which bind was attempted. Both migration scripts pass the password by environment now. The canonical export, generated LDIF and Keycloak realm were written 0644. None carries credential material, but the snapshot is every username, display name, email and group membership in the estate, and it tends to land in /tmp. All three are 0600. The image packaged keycape alone, so the validator and migration binaries needed a Go toolchain on the host -- which defeats shipping an image for the cutover work they exist to support. All five ship; the issuer stays the entrypoint. Verified by building the image and running each binary inside it. The publish workflow named 92.205.130.254:32166 while the cluster runs forgejo.coulomb.social/coulomb/key-cape. It now defaults to the recorded name and stays overridable by a repository variable. This repository cannot verify that the runner resolves that hostname or that the registry credentials are valid for it; if the next publish fails, set the REGISTRY variable back to the address. docker-compose.dev.yml mounts a private key and Authelia material that are correctly absent from the checkout. scripts/bootstrap-dev.sh generates them locally under a restrictive umask rather than chmodding afterwards, so the key is never briefly world-readable. Everything it writes is git-ignored. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NV9oijZukGyGbRQGGKnK4P Assistant: claude-code Assistant-Model: opus Assistant-Process: 713576@bnt-lap001 Assistant-Session: 384c511d-9bce-4cb8-a676-2aef6c0c8df6
2026-09-08 09:51:59 +02:00
Compose needs key and Authelia material absent from the checkout by design;
`scripts/bootstrap-dev.sh` generates it locally and git-ignores it
(KEY-WP-0026). Production
deployment and custody are external; a source implementation or example client
fragment does not prove live registration or consumer cutover.
Close the KEY-WP-0009 handoff gap and deliver the two blocked admissions KEY-WP-0009-T04 is done. Re-ran verification on main with an explicit Go 1.23.0 toolchain and a writable build cache: contract validator PASS, build, vet and the full test suite pass, git diff --check clean. gofmt had drifted on internal/server/telemetry/events.go and tests/migration/scenario_c_test.go (comment alignment only, no semantic change); both reformatted so the task's formatting claim holds. The four handoffs to netkingdom, secrets-engine, ops-warden and railiance-platform were then actually sent -- the 2026-09-05 audit had shown the earlier claimed delivery had no receipts -- and each receipt id is recorded in the workplan and readable back from the hub. KEY-WP-0013-T02 and KEY-WP-0014-T04 stay in wait. Their remaining work is not ours to do, so what was owed was the request, and it is now delivered: custody admission for the two approval clients to railiance-platform, the exact human client id and callback URI to approval-engine, and the OpenBao-token vs issuer-JWT split plus Qonto rotation execution authority to ops-warden. Each message quotes the existing non-secret packet or reviewed sequence and states what is explicitly not being treated as authorization. No secret was read, no production resource changed, no route retired. SCOPE.md updated to match. 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-08 10:22:52 +02:00
- Notification receipt closure is done: KEY-WP-0009 is finished, its four handoffs
delivered with receipts readable via `GET /messages/?from_agent=key-cape`.
Approval-client provisioning (KEY-WP-0013-T02) and coordinated Qonto rotation
plus the login-proxy consumer split (KEY-WP-0014-T04) remain open. Both requests
are now delivered and both wait on owners outside this repository —
railiance-platform for custody admission, approval-engine for the exact human
callback, ops-warden for the route contract and a rotation execution/rollback
authority. No client is materialized in the deployed image and no route changed.
## Deliberate exclusions
General-purpose IAM, dynamic client registration, implicit/password grants,
wildcard redirects, arbitrary identity brokering, resource authorization policy,
OpenBao credential custody, and Keycloak operations are outside this repository's
ownership. Registration/enrollment handoffs do not implement user provisioning
or factor enrollment themselves.
## Entry points and verification
Run the issuer with `keycape --config PATH`; `keycape server` and
`keycape migrate` are not implemented subcommands. Caller commands are
Reassess SCOPE against the current revision SCOPE.md declared itself reviewed against b989de4, 63 commits behind, with its limits kept current by appending to bullets. That works until the header asserts a review date that no longer describes what was reviewed -- the drift this file exists to prevent, in the document whose only job is to be accurate. Every claim was rechecked against source rather than carried forward: routes from the mux registrations, subcommands from the dispatch, flags from the flag sets, claim behaviour from token.go and userinfo.go. Three were false. Caller commands omitted verify-client, the container was said to package only keycape (untrue since KEY-WP-0026), and the issuer API row omitted /readyz. Capabilities that had reached SCOPE only piecemeal are now in the capability table: the authorization-code bindings, upstream token verification, readiness and drain, the human tenant rules, authentication-time assurance, the shared verifier. The substantive addition is a distinction SCOPE did not draw: source behaviour and deployed behaviour are different claims with different evidence -- the test suite and harness runs for one, committed receipts in docs/evidence/ for the other. What is proven live is narrower and now stated exactly, including that the issuer pin's pre-pin state did not match, so that risk was real rather than hypothetical, and that the deployed image predates this revision so several described behaviours are running nowhere yet. That distinction is the fix for a repeated error: four times in two days a claim about current state was written from a workplan paragraph or a partial view instead of the authoritative artifact. A SCOPE that does not separate "the code does this" from "this is running" invites the same mistake from the next reader. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NV9oijZukGyGbRQGGKnK4P Assistant: claude-code Assistant-Model: opus Assistant-Process: 713576@bnt-lap001 Assistant-Session: 384c511d-9bce-4cb8-a676-2aef6c0c8df6
2026-09-09 20:06:38 +02:00
`keycape login`, `keycape service-token` and `keycape verify-client`.
Separate binaries under `src/cmd/` are `validator`, `lldap-export`,
`keycape-to-keycloak`, and `lldap-to-ldap`. `make -C src build` places binaries in
Reassess SCOPE against the current revision SCOPE.md declared itself reviewed against b989de4, 63 commits behind, with its limits kept current by appending to bullets. That works until the header asserts a review date that no longer describes what was reviewed -- the drift this file exists to prevent, in the document whose only job is to be accurate. Every claim was rechecked against source rather than carried forward: routes from the mux registrations, subcommands from the dispatch, flags from the flag sets, claim behaviour from token.go and userinfo.go. Three were false. Caller commands omitted verify-client, the container was said to package only keycape (untrue since KEY-WP-0026), and the issuer API row omitted /readyz. Capabilities that had reached SCOPE only piecemeal are now in the capability table: the authorization-code bindings, upstream token verification, readiness and drain, the human tenant rules, authentication-time assurance, the shared verifier. The substantive addition is a distinction SCOPE did not draw: source behaviour and deployed behaviour are different claims with different evidence -- the test suite and harness runs for one, committed receipts in docs/evidence/ for the other. What is proven live is narrower and now stated exactly, including that the issuer pin's pre-pin state did not match, so that risk was real rather than hypothetical, and that the deployed image predates this revision so several described behaviours are running nowhere yet. That distinction is the fix for a repeated error: four times in two days a claim about current state was written from a workplan paragraph or a partial view instead of the authoritative artifact. A SCOPE that does not separate "the code does this" from "this is running" invites the same mistake from the next reader. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NV9oijZukGyGbRQGGKnK4P Assistant: claude-code Assistant-Model: opus Assistant-Process: 713576@bnt-lap001 Assistant-Session: 384c511d-9bce-4cb8-a676-2aef6c0c8df6
2026-09-09 20:06:38 +02:00
root `bin/` — the root `make build` only compiles and installs nothing. The
container packages all five binaries with the issuer as its entrypoint
(KEY-WP-0026). Tests live under `src/tests/` and alongside packages in
`src/internal/`.
`make test`, `make lint` (Go vet), `make build`, and `make contract-test` are the
root checks. The capability-contract check requires the sibling NetKingdom
validator. These checks passed for the reviewed revision in the preceding
Reassess SCOPE against the current revision SCOPE.md declared itself reviewed against b989de4, 63 commits behind, with its limits kept current by appending to bullets. That works until the header asserts a review date that no longer describes what was reviewed -- the drift this file exists to prevent, in the document whose only job is to be accurate. Every claim was rechecked against source rather than carried forward: routes from the mux registrations, subcommands from the dispatch, flags from the flag sets, claim behaviour from token.go and userinfo.go. Three were false. Caller commands omitted verify-client, the container was said to package only keycape (untrue since KEY-WP-0026), and the issuer API row omitted /readyz. Capabilities that had reached SCOPE only piecemeal are now in the capability table: the authorization-code bindings, upstream token verification, readiness and drain, the human tenant rules, authentication-time assurance, the shared verifier. The substantive addition is a distinction SCOPE did not draw: source behaviour and deployed behaviour are different claims with different evidence -- the test suite and harness runs for one, committed receipts in docs/evidence/ for the other. What is proven live is narrower and now stated exactly, including that the issuer pin's pre-pin state did not match, so that risk was real rather than hypothetical, and that the deployed image predates this revision so several described behaviours are running nowhere yet. That distinction is the fix for a repeated error: four times in two days a claim about current state was written from a workplan paragraph or a partial view instead of the authoritative artifact. A SCOPE that does not separate "the code does this" from "this is running" invites the same mistake from the next reader. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NV9oijZukGyGbRQGGKnK4P Assistant: claude-code Assistant-Model: opus Assistant-Process: 713576@bnt-lap001 Assistant-Session: 384c511d-9bce-4cb8-a676-2aef6c0c8df6
2026-09-09 20:06:38 +02:00
implementation session and were rerun for this one; `src/tests/conformance` is
additionally runnable against a live issuer via `KEYCAPE_TEST_ISSUER` and skips
when it is unset. The scope assessment is a source review: it establishes what
the code does, not what any deployment is currently running.
Use this repo for its bounded issuer, caller JWT acquisition, identity adapters
and migration preparation. Consult [INTENT.md](INTENT.md) for the architectural
target, [native authentication](docs/native-authentication.md) for caller
Reassess SCOPE against the current revision SCOPE.md declared itself reviewed against b989de4, 63 commits behind, with its limits kept current by appending to bullets. That works until the header asserts a review date that no longer describes what was reviewed -- the drift this file exists to prevent, in the document whose only job is to be accurate. Every claim was rechecked against source rather than carried forward: routes from the mux registrations, subcommands from the dispatch, flags from the flag sets, claim behaviour from token.go and userinfo.go. Three were false. Caller commands omitted verify-client, the container was said to package only keycape (untrue since KEY-WP-0026), and the issuer API row omitted /readyz. Capabilities that had reached SCOPE only piecemeal are now in the capability table: the authorization-code bindings, upstream token verification, readiness and drain, the human tenant rules, authentication-time assurance, the shared verifier. The substantive addition is a distinction SCOPE did not draw: source behaviour and deployed behaviour are different claims with different evidence -- the test suite and harness runs for one, committed receipts in docs/evidence/ for the other. What is proven live is narrower and now stated exactly, including that the issuer pin's pre-pin state did not match, so that risk was real rather than hypothetical, and that the deployed image predates this revision so several described behaviours are running nowhere yet. That distinction is the fix for a repeated error: four times in two days a claim about current state was written from a workplan paragraph or a partial view instead of the authoritative artifact. A SCOPE that does not separate "the code does this" from "this is running" invites the same mistake from the next reader. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NV9oijZukGyGbRQGGKnK4P Assistant: claude-code Assistant-Model: opus Assistant-Process: 713576@bnt-lap001 Assistant-Session: 384c511d-9bce-4cb8-a676-2aef6c0c8df6
2026-09-09 20:06:38 +02:00
constraints, [operations](docs/operations.md) for the supported topology and the
unreleased fail-closed changes, `docs/evidence/` for what has been proven against
the live deployment, and the [reassessment](history/2026-09-09-scope-reassessment.md)
— with the [2026-09-05 assessment](history/2026-09-05-011726-scope-intent-assessment.md)
it supersedes — for evidence, priorities and remaining gaps.
## Provided capabilities
```capability
type: security
title: Bounded OIDC and service-token issuance
description: Implements static-client browser PKCE and client-credentials authentication with RS256 identity claims through Authelia, LLDAP and privacyIDEA; complete Keycloak interchangeability remains unproven.
keywords: [oidc, pkce, authentication, iam, sso, mfa, identity, service-token]
```
```capability
type: security
title: Verified caller JWT acquisition
description: Provides native browser login and service-token exchange commands with HTTPS discovery, JWKS verification and private token-file delivery.
keywords: [oidc, pkce, jwt, cli, authentication, jwks]
```
```capability
type: security
title: Directory validation and migration artifact generation
description: Checks canonical snapshots and produces LLDAP exports, basic Keycloak realm JSON and target-specific LDIF; does not perform or prove a complete live migration.
keywords: [migration, identity, lldap, keycloak, ldif, validation]
```