All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 37s
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
373 lines
23 KiB
Markdown
373 lines
23 KiB
Markdown
# KeyCape scope against intent — 2026-09-05
|
||
|
||
Assessment timestamp: **2026-09-05 01:17:26 CEST (+02:00)**.
|
||
Source baseline: **b989de4e90248ff0782a3e99bc12a6d0c01487a4**.
|
||
Inputs: [INTENT](../INTENT.md), the previous SCOPE, implementation, tests,
|
||
build/operational scaffolding, and open workplans. Result: updated
|
||
[SCOPE](../SCOPE.md). INTENT is unchanged.
|
||
|
||
## Assessment
|
||
|
||
KeyCape substantially implements its identity-tooling purpose: it is a real
|
||
issuer with browser and service authentication, identity adapters, MFA policy
|
||
handling and caller commands. Its ownership boundary is consistent with INTENT:
|
||
it produces identity claims and delegates resource authorization and custody.
|
||
|
||
The stronger maturity claims are not established. “Strict” complete profile
|
||
conformance, stable identity through replacement, “seamless migration,” and
|
||
interchangeability without application changes exceed current implementation and
|
||
proof. The original 23-task workplan's completion is historical delivery evidence,
|
||
not evidence that every current contract, migration path or operational need is
|
||
complete. The accurate posture is **an implemented lightweight authentication
|
||
subset with tested local behavior and significant conformance, migration and
|
||
operational gaps**.
|
||
|
||
## Method and evidence limits
|
||
|
||
This is a source and documentation assessment, not a new live security audit or
|
||
migration rehearsal. The preceding implementation session at this baseline passed
|
||
`make test`, `make lint`, `make build` and `make contract-test`. No code changed in
|
||
this assessment and those checks were not rerun solely for wording changes.
|
||
Links and source claims were checked directly. Passing tests describe the covered
|
||
behavior; they do not establish the missing properties below.
|
||
|
||
Deployment observations in KEY-WP-0013 are earlier, explicitly dated evidence;
|
||
no fresh rollout, secret read, production token exchange or Keycloak migration
|
||
was performed here. Native caller behavior was tested locally, not certified as
|
||
a completed live consumer handoff. Findings marked as static risks need focused
|
||
regression tests before claiming exploitability or remediation.
|
||
|
||
## Alignment with INTENT
|
||
|
||
| INTENT commitment | Assessment | Evidence and limit |
|
||
| --- | --- | --- |
|
||
| Lightweight authentication Tooling | Substantially implemented | [Server composition](../src/cmd/keycape/main.go), [OIDC handlers](../src/internal/server/oidc/) and [adapters](../src/internal/adapters/). KeyCape signs its own tokens; it is more than a packaged reverse proxy. |
|
||
| Versioned, implementation-independent contract | Partial | Runtime supports newer service/audience policy than the v0.1 machine model and discovery metadata describe (G02). |
|
||
| Strong constraints and explicit rejection | Partial | Exact redirects, PKCE, scope checks, client-secret validation and [enforcement middleware](../src/internal/server/errors/enforcement.go) exist. Important validation and code-consumption boundaries remain incomplete (G01). |
|
||
| Canonical identity normalization | Partial | LDAP identities and group-derived tenant/roles are mapped. Directory portability, complete export and schema enforcement remain limited (G03/G05/G06). |
|
||
| Complete migration and interchangeable modes | Not demonstrated | Basic transforms and fixture tests exist. Current identity/client policy is not preserved end to end and live replacement harnesses are incomplete (G03/G04). |
|
||
| Deterministic, testable behavior | Strong local coverage; operational limits | Handler and CLI tests exercise actual local protocol code. Shared state, dependency readiness, complete exports and live backend replacement are not demonstrated (G04/G05/G08). |
|
||
| Minimal, secure, operationally efficient deployment | Partial | Small Go/container implementation exists; no resource benchmark or production-readiness certification was established. Bootstrap scaffolding and custody lifecycle need work (G08/G09/G10). |
|
||
| Authentication without resource authorization ownership | Aligned in scope | Claim issuance and local client/MFA rules are authentication policy; resource decisions remain with access-engine/consumers. This does not prove that every estate caller follows the engine-only integration rule. |
|
||
|
||
## Gaps and closure criteria
|
||
|
||
### G01 — Protocol trust and authorization-code consumption need hardening
|
||
|
||
**Priority: high. Kind: implementation gap / static security risk.**
|
||
|
||
[TokenHandler](../src/internal/server/oidc/token.go) validates PKCE, client ID and
|
||
scopes, but the authorization-code path does not authenticate confidential
|
||
clients or compare the submitted redirect URI with the stored one. Grant-type
|
||
eligibility is explicitly enforced on the service path, not equivalently on the
|
||
browser path. [SessionStore](../src/internal/server/oidc/session.go) retrieves a
|
||
code and deletes it in separate operations after signing; simultaneous requests
|
||
can reach the same session before deletion. This is not atomic single-use
|
||
consumption.
|
||
|
||
[UserInfo](../src/internal/server/oidc/userinfo.go) verifies an RSA signature and
|
||
expiry, but its `Issuer` field is unused in verification and the helper does not
|
||
validate the JOSE header algorithm, audience or token purpose. It cannot claim
|
||
the same verification contract as the new caller CLI.
|
||
|
||
The [Authelia adapter](../src/internal/adapters/authelia/adapter.go) deliberately
|
||
decodes upstream ID-token claims without signature verification and does not
|
||
validate their issuer/audience/expiry. Its comment assumes a trusted TLS service
|
||
boundary; the configuration permits internal HTTP endpoints. This is an explicit
|
||
trust assumption, not independent provider-token verification.
|
||
|
||
**Close when:** the accepted profile defines these bindings and trust boundaries;
|
||
implementation enforces them; negative tests cover confidential clients,
|
||
redirect/grant mismatch, issuer/token-purpose mismatch and concurrent code reuse.
|
||
Validate upstream provider tokens or explicitly establish and test the chosen
|
||
transport/trust contract. This assessment does not claim a demonstrated attack.
|
||
|
||
**Status 2026-09-06 (KEY-WP-0016): partially closed.** The local protocol
|
||
surface is now enforced and covered by negative tests — redirect-URI binding,
|
||
grant-type eligibility on the browser path, confidential-client authentication
|
||
with a constant-time comparison, atomic single-use code consumption (the
|
||
Get/Delete race was reproduced first: 9 of 16 concurrent exchanges succeeded
|
||
before the fix), and UserInfo algorithm, issuer and access-token-purpose checks.
|
||
Still open: the Authelia adapter's unverified upstream ID-token claims and its
|
||
transport-trust assumption, which is a trust-contract decision rather than a
|
||
local binding. G01 is not fully closed until that is settled, and none of this
|
||
establishes complete profile conformance.
|
||
|
||
**Status 2026-09-07 (KEY-WP-0019): closed.** Upstream ID tokens are now verified
|
||
before any claim is trusted — RS256 signature against Authelia's published keys,
|
||
the issuer Authelia advertises, KeyCape's own client ID in the audience, and a
|
||
sane validity window — failing closed when the key set is unavailable or
|
||
unparseable. Provider key rotation is picked up on one refresh without a restart.
|
||
|
||
The operator decision (2026-09-07) was to verify the token rather than enforce
|
||
the transport: the hop is to be HTTPS as defence in depth, but KeyCape does not
|
||
monitor, check or gate on that, because 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 the absence of HTTPS
|
||
validation here is a choice, not an oversight.
|
||
|
||
Both verification paths — the upstream adapter and the caller-side
|
||
`authclient` — now run on one implementation in `internal/jose`, so the strict
|
||
RS256/JWKS rules cannot drift apart or be fixed in only one copy. Claim policy
|
||
stays with each caller, whose issuer, audience and nonce bindings genuinely
|
||
differ.
|
||
|
||
Verified the tests catch the original defect: with the unverified parse restored,
|
||
all thirteen rejection cases plus the algorithm and rotation cases fail, and with
|
||
the shared signature check disabled both callers' suites fail. Complete profile
|
||
conformance is still not claimed.
|
||
|
||
### G02 — Machine-readable contract and discovery lag the runtime
|
||
|
||
**Priority: high. Kind: contract drift.**
|
||
|
||
[spec/canonical-model.yaml](../spec/canonical-model.yaml) restricts grants to
|
||
`authorization_code`, requires redirect URIs for every client, and omits newer
|
||
service subject, tenant, audience, lifetime and MFA/handoff policy fields present
|
||
in [domain/model.go](../src/internal/domain/model.go) and
|
||
[config.go](../src/internal/config/config.go). Both the YAML and Go comments claim
|
||
to be the source of truth, without a demonstrated generation/conformance link.
|
||
|
||
[Discovery](../src/internal/server/oidc/discovery.go) advertises both grants but
|
||
uses a fixed basic scope/claim list that omits newer core claims and configured
|
||
resource scopes. It is not a complete inventory of the current profile surface.
|
||
|
||
**Close when:** select a canonical version, reconcile schema/runtime/discovery,
|
||
and add executable compatibility checks for human and service registrations.
|
||
Distinguish required profile claims from optional discovery metadata rather than
|
||
assuming every omission has the same protocol impact.
|
||
|
||
**Status 2026-09-07 (KEY-WP-0017): closed for client registration and
|
||
discovery.** The Go model is now stated as the runtime authority and the
|
||
canonical model as the reviewed contract, with a two-way conformance test
|
||
holding them together — it rejects a runtime field with no spec entry and a spec
|
||
field the runtime does not read, the latter unless marked `runtime: false`. The
|
||
check found drift beyond the assessment's list on its first run (`User.tenant`
|
||
was undeclared), which is the argument for the check over the one-time edit. The
|
||
`Client` entity gained the audience, service-subject, tenant, role, MFA and
|
||
handoff fields, `grantTypes` gained `client_credentials`, and `redirectUris` is
|
||
no longer required of service-only clients. Discovery now advertises the core
|
||
profile claims and derives `scopes_supported` from the registered clients.
|
||
Still open: this covers the client-registration and discovery surface, not
|
||
schema enforcement in general, which remains G06.
|
||
|
||
### G03 — Migration does not preserve the current authentication contract
|
||
|
||
**Priority: high. Kind: implementation gap.**
|
||
|
||
The [Keycloak CLI](../src/cmd/keycape-to-keycloak/main.go) calls `Transform`, which
|
||
passes no clients. Only the library's `TransformWithClients` accepts them.
|
||
The [transformer](../src/internal/migration/tokeycloak/transformer.go) leaves realm
|
||
roles and client-scope definitions empty, always enables standard flow, and lacks
|
||
mapping for service-account issuance, resource audiences, tenant/role claims,
|
||
per-client lifetime, MFA policy and secret references. Its user mapping omits the
|
||
canonical ID, tenant and roles; retaining the same `sub` is not established.
|
||
LLDAP uses a DN as the canonical user ID, so directory relocation itself requires
|
||
an explicit identity continuity strategy. Passwords and MFA credentials are not
|
||
migrated; absence of MFA data is not proof that no re-enrollment is needed.
|
||
|
||
**Close when:** the CLI accepts the required complete snapshot/registrations,
|
||
transforms preserve or explicitly reject every relevant policy/identity field,
|
||
and migration proof demonstrates subject continuity, claims, MFA and client
|
||
behavior. Until then, call these artifact generators rather than full migration.
|
||
|
||
**Status 2026-09-07 (KEY-WP-0020): semantic preservation closed; proof remains
|
||
G04.** The CLI takes `-clients` and reaches `TransformWithClients`, so the realm
|
||
carries the registration contract instead of an empty array. Flows are derived
|
||
from the declared grants rather than hardcoded — the previous mapping enabled the
|
||
browser flow on every service-only client, widening it during migration. The
|
||
audience, tenant, service subject and roles ride as protocol mappers, per-client
|
||
lifetime and handoff URLs as attributes, and the secret *reference* as an
|
||
attribute, never a value; a test asserts no resolved secret can appear in the
|
||
JSON. Realm roles and client scopes are derived from the identities and
|
||
registrations present, replacing empty containers that made a realm dropping
|
||
every role look like one that had none.
|
||
|
||
What cannot be carried is now named rather than dropped, which was the real
|
||
defect: `UnpreservedReport` is deliberately separate from `ValidationReport`, so
|
||
"the realm matches the snapshot" and "the migration is complete" stay distinct
|
||
questions. It names the unmigrated secret, the unenforceable MFA policy,
|
||
passwords and factor enrolment, and subject continuity — Keycloak mints its own
|
||
`sub`, so relying parties keyed on it will not recognise migrated users. An
|
||
incomplete transform emits `partial` telemetry, matching KEY-WP-0018.
|
||
|
||
Not closed: this is preservation and honest reporting, not proof. Whether an
|
||
imported realm actually issues profile-conformant tokens is G04, and password and
|
||
MFA credential migration remains out of scope.
|
||
|
||
### G04 — The replacement test harness does not prove a live provider swap
|
||
|
||
**Priority: high. Kind: verification and tooling gap.**
|
||
|
||
[Scenario B](../scripts/test-scenario-b.sh) and
|
||
[Scenario C](../scripts/test-scenario-c.sh) reference absent
|
||
`docker-compose.scenario-b.yml` / `docker-compose.scenario-c.yml`. They expect
|
||
`src/bin/`, while [src/Makefile](../src/Makefile) builds into root `bin/`, and invoke
|
||
a workstation-specific Go path from outside the Go module. Scenario C passes
|
||
`--base-dn` to a generator whose flag is `--basedn`.
|
||
|
||
Both scripts set `KEYCAPE_TEST_ISSUER`, but the
|
||
[profile suite](../src/tests/profile/profile_test.go) constructs its own
|
||
`httptest` server and does not consume that variable. The
|
||
[migration suites](../src/tests/migration/) validate generated structures with
|
||
fixtures. Thus even repairing the shell prerequisites would not make those
|
||
profile tests exercise the external Keycloak issuer.
|
||
|
||
**Close when:** reproducible stacks and an externally targeted conformance suite
|
||
exercise actual replacement providers, directory migration, claims and MFA;
|
||
record unchanged relying-party behavior and explicit migration limitations.
|
||
|
||
### G05 — Directory export can omit data without reporting it
|
||
|
||
**Priority: medium. Kind: implementation gap.**
|
||
|
||
The [exporter](../src/internal/migration/lldapexport/exporter.go) discovers groups
|
||
through each user's memberships. Empty/unreferenced groups are not enumerated.
|
||
Group lookup errors are skipped even though a comment says they are recorded in
|
||
the incompatibility report. The emitted success event and report therefore do
|
||
not establish a complete export. Iterating the group map also leaves group order
|
||
unspecified.
|
||
|
||
**Close when:** enumerate all groups independently, report or fail on incomplete
|
||
reads, define ordering, and test empty groups and backend failures. Preserve
|
||
completeness evidence before claiming deterministic full snapshots.
|
||
|
||
**Status 2026-09-07 (KEY-WP-0018): closed.** `LDAPAdapter` gained a
|
||
`ListGroups` enumeration over the group subtree, offered to the exporter through
|
||
an optional `domain.GroupLister` rather than by widening `UserRepository`, so
|
||
groups with no members are in the snapshot and `Group.Members` is populated from
|
||
the directory. Reading the adapter to write it surfaced a defect the assessment
|
||
had not listed: `LookupGroups` never set `Members`, and the exporter built every
|
||
membership from that field, so a real export's `memberships` block was always
|
||
empty while the fixture-backed tests passed. Each result now carries
|
||
`groupEnumeration` (`directory` or `membership-derived`) and a `Complete()`
|
||
predicate; a failed enumeration aborts instead of writing a smaller snapshot, a
|
||
failed per-user lookup on the fallback path is reported rather than dropped, an
|
||
incomplete run emits `partial` telemetry, and the CLI names the mode. Users,
|
||
groups and memberships are sorted on stable keys. Tests cover the empty group,
|
||
the enumeration failure, the fallback lookup failure and repeat-run determinism.
|
||
This is completeness evidence for the user/group/membership surface only —
|
||
credential migration remains out of scope under G03.
|
||
|
||
### G06 — The validator is narrower than schema enforcement
|
||
|
||
**Priority: medium. Kind: implementation/claim gap.**
|
||
|
||
[validator.go](../src/internal/validator/validator.go) implements useful structural
|
||
and semantic checks. However, `checkNoUnknownAttributes` is a placeholder that
|
||
rejects blank keys rather than enforcing an attribute allow-list; group
|
||
membership validation checks nonempty member IDs rather than complete referenced
|
||
identity existence. It does not derive its checks from the YAML schema.
|
||
|
||
**Close when:** implement the intended allow-list/reference constraints or narrow
|
||
the normative contract to the actual checks; add invalid-snapshot cases that
|
||
prove each stated rule. SCOPE now calls this limited snapshot validation.
|
||
|
||
### G07 — Optional tenant-role support is not wired into the server
|
||
|
||
**Priority: medium. Kind: integration gap.**
|
||
|
||
The [tenant-engine client](../src/internal/adapters/tenantengine/adapter.go) and
|
||
`TokenHandler.TenantEngine` are implemented and tested, including omission on
|
||
failure. [main.go](../src/cmd/keycape/main.go) supplies no such client and exposes
|
||
no configuration for it. The stock server therefore leaves it nil and omits
|
||
`tenant_roles`.
|
||
|
||
**Close when:** wire an explicit opt-in configuration and verify the built
|
||
executable, or document this as library-only support. It is an optional cache
|
||
claim, so absence is not itself a resource authorization failure.
|
||
|
||
### G08 — Runtime lifecycle and readiness are intentionally minimal
|
||
|
||
**Priority: medium. Kind: operational maturity gap.**
|
||
|
||
[Authorization state](../src/internal/server/oidc/authorize.go),
|
||
[code sessions](../src/internal/server/oidc/session.go),
|
||
[login sessions](../src/internal/server/oidc/login_session.go) and
|
||
[handoffs](../src/internal/server/oidc/handoff.go) reside in memory. Restart loses
|
||
in-flight and login state; multi-replica behavior is not supported by a shared
|
||
store. `/healthz` in [main.go](../src/cmd/keycape/main.go) returns a constant process
|
||
response without testing dependencies. The server uses `ListenAndServe`; TLS
|
||
termination is external. The signing key and registrations load
|
||
at startup; a fixed `key-1` identifier is used by token issuance.
|
||
|
||
[Logout](../src/internal/server/oidc/logout.go) clears the local login session;
|
||
it is not upstream logout or JWT revocation. No general refresh/introspection/
|
||
revocation or automatic rotation service is exposed. These need explicit
|
||
operational limits rather than an unqualified “high stability” label.
|
||
|
||
**Close when:** document/test the supported deployment topology, readiness and
|
||
restart behavior, and coordinate key/client lifecycle and consumer refresh.
|
||
Shared storage or refresh tokens need not be added if the accepted profile
|
||
explicitly excludes them. Benchmark before asserting resource-efficiency bounds.
|
||
|
||
### G09 — Packaging/bootstrap and older CLI credential handling need reconciliation
|
||
|
||
**Priority: medium. Kind: operational/tooling gap.**
|
||
|
||
[Development Compose](../docker-compose.dev.yml) refers to `config/dev-key.pem`
|
||
and `config/authelia`, neither supplied in the checkout. It is a scaffold requiring
|
||
bootstrap material. The [Dockerfile](../Dockerfile) packages only `keycape`, not
|
||
the migration/validator binaries. Image publication still points at the older
|
||
registry address in [.gitea/workflows/image.yaml](../.gitea/workflows/image.yaml);
|
||
that requires reconciliation with the Forgejo image location recorded by the
|
||
live workplan, not an assumption that every push deployed the current source.
|
||
|
||
The older [LLDAP exporter CLI](../src/cmd/lldap-export/main.go) accepts the bind
|
||
password on argv, and the migration scripts use that path. This falls short of
|
||
the newer caller CLI's private credential transport posture. Snapshot and realm
|
||
output are written with mode 0644, so operators must also consider identity-data
|
||
handling even though credential secrets are not part of those exports.
|
||
|
||
**Close when:** supply a reproducible bootstrap procedure, correct executable/
|
||
artifact locations and release references, and adopt safe credential input and
|
||
appropriate export permissions. A documented external bootstrap may satisfy
|
||
scope without storing secrets in this repository.
|
||
|
||
### G10 — Source capability is ahead of live custody and consumer adoption
|
||
|
||
**Priority: high for rollout; medium for handoff hygiene. Kind: external dependency/proof gap.**
|
||
|
||
[KEY-WP-0013](../workplans/KEY-WP-0013-approval-engine-resource-audience.md) records
|
||
approval clients awaiting custody admission, exact human callback and live proof.
|
||
Its earlier deployment observation identifies `main-153258b`, not the assessed
|
||
source revision. The [provisioning packet](../docs/approval-engine-provisioning-request.yaml)
|
||
is proposed metadata, not executable authorization or a live registration.
|
||
|
||
[KEY-WP-0014](../workplans/KEY-WP-0014-native-credential-lane-handoff.md) records
|
||
native JWT commands as implemented, but coordinated Qonto rotation and consumer
|
||
handoff remain open. The existing ops-warden login route obtains an **OpenBao
|
||
token**, so replacing it with issuer-JWT output would change the consumer
|
||
contract. [KEY-WP-0009](../workplans/KEY-WP-0009-provider-capabilities-and-service-identities.md)
|
||
was reopened because claimed handoff delivery lacks matching current receipts.
|
||
|
||
**Close when:** named custody/platform owners admit and provision exact lanes,
|
||
register the real callback, deploy and verify the new contracts, reconcile token
|
||
types at consumer boundaries, and retain handoff receipts. Repo-local source
|
||
changes cannot alone establish these outcomes.
|
||
|
||
## Deliberate exclusions are not defects
|
||
|
||
INTENT excludes general-purpose IAM, weakened flows and expanded-mode operations.
|
||
Dynamic registration, implicit/password grants, wildcard redirects and arbitrary
|
||
brokering should stay excluded unless the accepted profile changes. Resource
|
||
policy decisions and secret custody likewise remain with their owners.
|
||
|
||
The meaningful gaps are incomplete delivery or proof of the repo's claimed
|
||
subset, plus unqualified maturity claims. Full Keycloak feature parity, building
|
||
an authorization engine, or taking over OpenBao is not the proposed remedy.
|
||
|
||
## Recommended order
|
||
|
||
1. Resolve G01 protocol bindings and G02 canonical contract drift before claiming
|
||
complete profile conformance or widening rollout.
|
||
2. Treat G03–G06 as a migration workstream: completeness and semantic preservation
|
||
first, then actual provider-replacement proof.
|
||
3. Decide G07 opt-in wiring and document/test the G08 supported topology.
|
||
4. Reconcile G09 bootstrap/release paths and complete G10 owner admissions and
|
||
live proof. Do not confuse passing local tests with those handoffs.
|
||
|
||
The findings above are an assessment backlog, not completed fixes or newly
|
||
approved production changes. Existing workplan references are retained where
|
||
applicable; new engineering work needs scoped implementation plans. This task
|
||
changes documentation only.
|