Commit graph

14 commits

Author SHA1 Message Date
a0f3cac122 Wire tenant_roles as explicit opt-in configuration
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 48s
Closes gap G07. The tenant-engine client and TokenHandler.TenantEngine were
implemented and tested, but main.go supplied no client and exposed no
configuration, so the stock executable left the field nil and the capability
existed at library level only.

A tenantEngine block with baseURL and an optional timeout now wires it. An empty
baseURL leaves the stock server's behaviour exactly as it was, so enabling the
claim is a deliberate act. Validation treats a configured source as one that must
work: http/https with a host, and a timeout in (0, 10s] since it sits on the
synchronous token-issuance path. A timeout set without a baseURL is rejected
rather than ignored -- it means someone expected the claim to be on.

Verified in the built executable, which is what G07 asks for, rather than at the
wiring: against a stub source a real token carries tenant_roles; with no block
the claim is absent; with the source configured but down, issuance succeeds
without it, confirming the documented fail-open path end to end.

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 08:59:40 +02:00
e729ad4c28 Prove the migration against live directories and fix what that surfaced
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 41s
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
7534552754 Make the replacement harness runnable and target a live issuer
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 36s
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
21acb5cdd6 Make snapshot attribute validation enforce a real rule
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 40s
Closes gap G06. checkNoUnknownAttributes was named for a rule it did not
implement: it rejected blank keys, which is not an allow-list, so a snapshot
carrying malformed or shadowing attribute names passed a check whose name said
otherwise. A rule that passes for the wrong reason is worse than an absent one,
because the report is read as evidence.

raw_attributes_well_formed requires each key to be a valid LDAP attribute
descriptor, forbids shadowing an attribute the canonical model owns (uid, cn,
mail) in any casing, and rejects keys differing only by case, which LDAP treats
as one attribute. spec/ldap-schema.yaml carries the same wording.

Two corrections to the gap's description rather than implementations of it. An
allow-list is not derivable: ldapAttributes is defined as what the canonical
model does not cover, so the schema cannot enumerate what may appear there. And
the reference constraint already existed -- checkValidGroupMemberships only
checks emptiness, but the semantic rule checkReferencedUsersExist resolves every
member against the user set.

Neutering the rule fails four of the five new tests.

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:22:55 +02:00
e9fc8544ab Make the Keycloak transform preserve or name every policy field
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
2026-09-07 13:48:48 +02:00
91e3d50907 Verify upstream Authelia ID tokens
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 29s
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
f7dd51b8d2 Make the LLDAP export report its own completeness
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 31s
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
a770ac67d0 Reconcile the canonical model and discovery with the runtime
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 40s
Closes gap G02 of the scope assessment for the client-registration and discovery
surface. spec/canonical-model.yaml and domain/model.go both claimed to be the
source of truth and disagreed: the spec restricted grants to authorization_code,
required redirect URIs of every client, and omitted the audience, service
subject, tenant, role, MFA and handoff fields the runtime reads.

The durable part is the link, not the edit. A two-way conformance test compares
the spec against the Go model by reflection and fails when a runtime field has
no spec entry or a spec entry is not read by the runtime, the latter unless
marked runtime: false. It found drift beyond the assessment's list on its first
run -- User.tenant was undeclared -- which is the argument for the check over a
one-time reconciliation.

Discovery now advertises the core profile claims that appear on every token and
derives scopes_supported from the registered clients rather than a fixed list.

The Go model is stated as the runtime authority and the YAML as the reviewed
contract, in both files. This covers client registration and discovery, not
schema enforcement in general, which remains G06.

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 00:22:49 +02:00
0d7e2f6b41 Document the authorization-code bindings for relying parties
KEY-WP-0016 changed /token and /userinfo behaviour with no consumer-facing note;
nothing in docs/ mentioned redirect_uri, so the change would have reached a
deployment silently.

States what an exchange must now send, who is affected and how to roll out.
Every browser registration in dev-config is public with an authorization_code
grant, so only the redirect_uri requirement can affect them; the realistic
failure is a client that sends it to /authorize and omits it at /token, which
has not been observed against a live consumer.

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 00:17:56 +02:00
139b6ff351 Harden the authorization-code grant and UserInfo verification
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 37s
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
30fa8570aa Document actual capabilities and assess scope against intent
Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a06e87-e039-7ed2-b85c-20ad37f8a21b
2026-09-05 01:27:16 +02:00
a626dd5d4e Scope update from repo-scoping refactor 2026-05-01 12:26:34 +02:00
303663e48b Enhanced scope with provided capabilities 2026-03-19 21:41:24 +01:00
80bf79de46 docs: add SCOPE.md for rapid orientation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 23:10:44 +01:00