From 68d6a6edd849215b0311d9267f5034e00788432d Mon Sep 17 00:00:00 2001 From: cuioss oliver <23139298+cuioss@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:57:24 +0200 Subject: [PATCH 1/2] chore(deps): true up token-sheriff floor and audit the 0.9.3 delta version.token-sheriff is asserted (not edited) at 0.9.3, the confirmed latest upstream release. The build metadata that went stale around it is corrected. Re-derived the smallrye-config convergence as a real resolved-dependency check (dependency:tree -Dincludes=io.smallrye.config:*, -Dverbose, and dependency:list across the reactor) rather than trusting the existing comment: - io.smallrye.config converges on a single 3.17.2 across the whole reactor at compile scope. No split, so no structural pom change was warranted. - token-sheriff-bom 0.9.3 declares its Quarkus artifacts (quarkus-config-yaml, quarkus-hibernate-validator) at 3.38.0; the quarkus-bom-first import pins them back to 3.37.4. The ordering seam still does its job. The pom.xml:83-87 comment now names the versions that actually resolve, replacing the stale "Quarkus 3.37.2's native 3.17.2 / cui-java-parent bumps to 3.18.0" wording. CLAUDE.md's Project Overview is corrected from Quarkus 3.37.2 to the 3.37.4 that pom.xml:53 pins. Retrospective whole-delta audit of the 16 PRs that landed unreviewed via the bot bump; full verdict record in the plan's work/delta-audit-verdicts.md: - #581 flipped IssuerConfig.azpAudienceFallbackEnabled from true to false. The audience-less path (TokenValidatorProducer:128-130) is unaffected because audience validation is disabled wholesale; the audience-configured path (:129) IS affected at runtime -- a token with a matching azp but no aud was accepted under 0.9.2 and is rejected under 0.9.3. Documented on the issuers[].audience row in doc/configuration.adoc. - #607 (step-up fail-closed on blank constraints) has no gateway impact: the gateway never builds a StepUpChallenge, detectChallenge short-circuits blank headers before the parser, and no path relies on an empty ACR being a no-op. - #593 (upstream Quarkus 3.38.0) is absorbed by the import ordering, verified above rather than assumed. - The remaining PRs are verdicted individually on their diffs, not their titles. The request's third named change ("HTTP client caching is now scheme-keyed") is recorded as OPEN rather than closed: settling it needs the diff hunks of #575 and #577, and only their changed-file lists were available. Filed as a triage finding. No file under api-sheriff/src is modified. Co-Authored-By: Claude --- CLAUDE.md | 2 +- doc/configuration.adoc | 7 ++++++- pom.xml | 11 +++++++---- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b7f3e75d..b5a30392 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code when working with this repository. ## Project Overview -API Sheriff is a security-focused API Gateway with a lightweight approach, currently in pre-1.0 development. Built with Maven, Java 25 (compile + runtime; CI matrix 25 + 26), and Quarkus 3.37.2. Follows CUI (CUIoss) standards. +API Sheriff is a security-focused API Gateway with a lightweight approach, currently in pre-1.0 development. Built with Maven, Java 25 (compile + runtime; CI matrix 25 + 26), and Quarkus 3.37.4. Follows CUI (CUIoss) standards. ## Project Structure diff --git a/doc/configuration.adoc b/doc/configuration.adoc index e7c6b073..dceda153 100644 --- a/doc/configuration.adoc +++ b/doc/configuration.adoc @@ -1026,7 +1026,12 @@ link:variants/01-base-gateway.adoc[Variant 1]. Required when any route's *effect supported; the validator selects by the token's `iss`. | `issuers[].audience` -| The expected `aud` claim (maps to `IssuerConfig.expectedAudience`). +| The expected `aud` claim (maps to `IssuerConfig.expectedAudience`). *The `aud` claim is + required once an audience is configured*: as of token-sheriff 0.9.3 + `IssuerConfig.azpAudienceFallbackEnabled` defaults to `false`, so a matching `azp` no longer + substitutes for a missing `aud`. A token that previously passed on `azp` alone is now + rejected. If your IdP issues `azp`-only tokens, either configure it to emit `aud`, or omit + the `audience` key entirely -- omitting it disables audience validation wholesale. | `issuers[].jwks` | Key material for signature verification: `source: http` with a `url` (cached, periodically diff --git a/pom.xml b/pom.xml index c90f3760..f65748b8 100644 --- a/pom.xml +++ b/pom.xml @@ -81,10 +81,13 @@ import + versions. Declared after quarkus-bom on purpose: quarkus-bom is imported first, so it wins + every artifact the two BOMs both manage. Verified against the resolved dependency graph at + token-sheriff 0.9.3 / Quarkus 3.37.4: token-sheriff-bom declares its Quarkus artifacts + (quarkus-config-yaml, quarkus-hibernate-validator) at 3.38.0 and the quarkus-bom import pins + them back to 3.37.4, after which io.smallrye.config converges on a single 3.17.2 across the + whole reactor at compile scope. Reordering these two imports would pull a second Quarkus line + into the build and split smallrye-config, which breaks Quarkus config-mapping augmentation. --> de.cuioss.sheriff.token token-sheriff-bom From 3a5c4c6c72d665789adfea7f48d2e38c00affcdc Mon Sep 17 00:00:00 2001 From: cuioss oliver <23139298+cuioss@users.noreply.github.com> Date: Wed, 29 Jul 2026 17:34:19 +0200 Subject: [PATCH 2/2] docs(adr): record the BOM ordering and hardened-default adoption decisions ADR-0021 settles which BOM owns the shared Quarkus / smallrye-config version line: quarkus-bom is imported first and is the authority, every later BOM import is a subordinate. Reordering them splits smallrye-config at compile scope and breaks Quarkus config-mapping augmentation. ADR-0022 settles what the gateway does when its security dependency tightens a default: adopt it and document the narrowed operator contract, never restore the permissive behaviour locally. A tightened default the config model cannot accommodate is escalated as a finding, not patched around; a floor move is audited on the whole delta, not on the feature that motivated it. Both were surfaced by the phase-6 adr-propose gate on PLAN-31A and confirmed by the operator. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XC1JSf6dxTVa4S9RqvNJuY --- ...rkus_and_smallrye-config_version_line.adoc | 142 +++++++++++++++++ ...and_documented_never_locally_reverted.adoc | 149 ++++++++++++++++++ 2 files changed, 291 insertions(+) create mode 100644 doc/adr/0021-Quarkus_platform_BOM_is_imported_first_and_owns_the_Quarkus_and_smallrye-config_version_line.adoc create mode 100644 doc/adr/0022-Upstream_security-hardening_default_changes_are_adopted_and_documented_never_locally_reverted.adoc diff --git a/doc/adr/0021-Quarkus_platform_BOM_is_imported_first_and_owns_the_Quarkus_and_smallrye-config_version_line.adoc b/doc/adr/0021-Quarkus_platform_BOM_is_imported_first_and_owns_the_Quarkus_and_smallrye-config_version_line.adoc new file mode 100644 index 00000000..fbd7fb14 --- /dev/null +++ b/doc/adr/0021-Quarkus_platform_BOM_is_imported_first_and_owns_the_Quarkus_and_smallrye-config_version_line.adoc @@ -0,0 +1,142 @@ += ADR-0021: Quarkus platform BOM is imported first and owns the Quarkus and smallrye-config version line +:toc: left +:toclevels: 2 +:sectnums: + +// adr-metadata +// summary: The quarkus-bom import is declared first in dependencyManagement so the Quarkus platform owns the Quarkus and smallrye-config version line; every later BOM import is a subordinate that may only contribute versions the platform does not already pin. +// tags: build, dependencies, maven, bom, quarkus, smallrye-config +// affects: api-sheriff-parent +// supersedes: +// end-adr-metadata + +== Status + +Proposed + +== Context + +API Sheriff is a Quarkus application that imports more than one third-party BOM +into `dependencyManagement`. Quarkus is not an ordinary dependency: the platform +BOM pins a large, internally-consistent version line covering the Quarkus +extensions and their shared runtime libraries, of which `io.smallrye.config` is +the load-bearing one — the Quarkus build step that augments `@ConfigMapping` +interfaces at build time is compiled against a specific smallrye-config API. + +Any other BOM the project imports may itself have been built against a +*different* Quarkus release and therefore declares its own versions for the same +coordinates. Maven's `dependencyManagement` resolves an import collision by +**declaration order** — the first import to pin a coordinate wins — so the +relative order of two BOM imports silently decides which Quarkus line the reactor +resolves. + +That creates a structural choice point with a sharp failure mode. When a +subordinate BOM wins a Quarkus coordinate, the reactor can end up resolving two +different smallrye-config versions: one reaching compile scope through the +Quarkus extensions, another through the subordinate BOM's own transitives. A +smallrye-config split breaks Quarkus config-mapping augmentation, and it breaks +it at build time with an error that names neither BOM. The failure is not +proportional to its cause: a one-line reordering, or the addition of a new BOM +import in the wrong position, produces a build failure whose diagnosis requires +reconstructing the whole resolved dependency graph. + +The question worth settling is therefore not "which versions are correct today" +— those move on every upstream release — but **which BOM is the authority** when +two of them disagree. + +== Decision + +**The Quarkus platform BOM (`quarkus-bom`) is declared first in +`dependencyManagement`, and it owns the Quarkus and `io.smallrye.config` version +line for the whole reactor.** Every other BOM import is declared after it and is +a subordinate: it may contribute versions for coordinates the platform does not +already pin, and it may not override one that the platform does. + +Two obligations follow from the decision and are part of it: + +* **Import position is a contract, not formatting.** A new BOM import is added + *after* the `quarkus-bom` import. Reordering the existing imports is a + behavioural change to dependency resolution and is treated as such. +* **Convergence is verified against the resolved graph, not asserted in prose.** + When a dependency floor moves, the actual resolved `io.smallrye.config` version + is re-derived from the reactor (`dependency:tree` scoped to the coordinate) + and the recorded rationale is updated to name the versions that genuinely + resolve. A comment describing a convergence is evidence only for as long as + someone has re-run the check. + +The mechanism realising the decision is the ordering of the `` +import block in the root `pom.xml`, together with the comment adjacent to the +subordinate import that records the resolved outcome and the reason the ordering +matters. + +== Consequences + +=== Positive + +* A single Quarkus line, and therefore a single `io.smallrye.config` version, + reaches compile scope across the reactor — config-mapping augmentation resolves + against the API it was compiled for. +* A subordinate BOM built against a newer Quarkus release can be adopted for the + artifacts it actually provides without dragging its Quarkus line into the + build. +* The rule is checkable mechanically: the resolved coordinate is a single value + or it is not, and the check is one `dependency:tree` invocation. + +=== Negative + +* A subordinate BOM's own Quarkus expectations are silently overridden. If that + BOM genuinely requires a newer Quarkus API at runtime, the failure surfaces as + a `NoSuchMethodError` at execution rather than as a resolution conflict at + build time. +* The project cannot adopt a newer Quarkus line piecemeal for one subordinate's + benefit; moving the Quarkus version is a whole-reactor decision. + +=== Risks + +* **Ordering is invisible at the point of change.** Nothing in Maven's output + reports which BOM won a coordinate, so an incorrectly-positioned new import + looks like a clean build until the augmentation step fails. The residual + mitigation is the recorded rationale adjacent to the import block plus the + re-derivation obligation on every floor move — both are conventions, not + enforced gates. +* **The recorded convergence goes stale silently.** A comment naming concrete + versions is correct only until either BOM moves. It is treated as a claim to + re-verify, never as a fact to trust. + +== Alternatives Considered + +**Import the subordinate BOM first and let it own the shared coordinates.** +This would honour the subordinate's own build-time expectations exactly, which is +attractive when the subordinate is the component under active development. It is +rejected because it inverts the dependency: the platform that defines the +application's runtime becomes subordinate to a library that merely runs on it. +The resulting split reaches compile scope and breaks config-mapping augmentation +— a whole-application failure traded for one library's version preference. + +**Pin `io.smallrye.config` explicitly in `dependencyManagement` and leave the +BOM order unconstrained.** This makes the converged version explicit and immune +to import order, which is genuinely more legible. It is rejected as the primary +mechanism because it fixes one symptom of the ordering question rather than the +question: the same collision recurs for every other coordinate the two BOMs +share, and each recurrence needs its own explicit pin. An explicit pin remains +available as a targeted correction when a real split is demonstrated, but it is +not the governing rule. + +**Import only one BOM and manage the second BOM's artifacts by explicit +version.** This removes the collision entirely at the cost of hand-maintaining +every version the dropped BOM would have supplied, forfeiting exactly the +internal consistency a BOM exists to provide. It is rejected as a maintenance +burden disproportionate to the problem. + +The alternatives fall in one class: each accepts an ambiguous or hand-maintained +authority over the shared version line. The decision instead names a single +authority — the platform — and makes every other BOM subordinate to it. + +== References + +* `pom.xml` — the `` import block whose declaration order + realises this decision, and the adjacent comment recording the resolved + convergence. +* link:0001-java25-runtime-baseline.adoc[ADR-0001: Java 25 runtime baseline] — + the sibling build-baseline decision; the Quarkus line this ADR governs is what + supplies that baseline's platform support. diff --git a/doc/adr/0022-Upstream_security-hardening_default_changes_are_adopted_and_documented_never_locally_reverted.adoc b/doc/adr/0022-Upstream_security-hardening_default_changes_are_adopted_and_documented_never_locally_reverted.adoc new file mode 100644 index 00000000..11b37932 --- /dev/null +++ b/doc/adr/0022-Upstream_security-hardening_default_changes_are_adopted_and_documented_never_locally_reverted.adoc @@ -0,0 +1,149 @@ += ADR-0022: Upstream security-hardening default changes are adopted and documented, never locally reverted +:toc: left +:toclevels: 2 +:sectnums: + +// adr-metadata +// summary: When an upstream security dependency tightens a default, API Sheriff adopts the tightened default and documents the narrowed operator contract; it does not restore the permissive behaviour locally. A tightened default the gateway cannot accommodate is escalated as a config-model finding, not patched around. +// tags: security, dependencies, authentication, configuration, token-validation +// affects: api-sheriff +// supersedes: +// end-adr-metadata + +== Status + +Proposed + +== Context + +API Sheriff delegates JWT validation to an external security library. That +library's security posture is expressed largely through **defaults** — which +claims are required, which substitutions are permitted, which malformed inputs +fail open versus closed. A hardening release therefore does not usually add a new +API; it flips a default from permissive to strict. + +This creates a failure class distinct from an ordinary breaking change. A +tightened default: + +* **compiles cleanly.** No signature moved, so nothing in the gateway fails to + build. The change is invisible to the compiler. +* **is invisible to structural tests.** A test that asserts a validator was + constructed successfully passes identically before and after the flip, because + the flip changes what the constructed validator _accepts at runtime_, not + whether it can be built. A green suite is therefore not evidence that the + behaviour is unchanged. +* **is observable only at the trust boundary, in production.** The first signal + is a token that used to authenticate and now does not. + +Because a tightened default is easy to reverse — the library exposes the knob +that was flipped — there is a standing temptation to restore the permissive +behaviour locally to keep an existing deployment working. That choice is cheap at +the moment it is made and expensive afterwards: the gateway silently retains a +posture the library's maintainers deliberately abandoned, usually in response to +a real vulnerability class, and the divergence is recorded nowhere the next +reader will look. + +The structural question is what a security gateway does when its own upstream +tightens the rules underneath it. + +== Decision + +**When an upstream security dependency tightens a default, API Sheriff adopts +the tightened default.** The permissive behaviour is not restored locally — +not by re-enabling the flipped knob, not by reimplementing the relaxed path in +gateway code. + +Three obligations realise the decision: + +* **Adopt, then document the narrowed contract.** The behaviour change is + operator-visible even when no gateway code changes, so the configuration + reference states the tightened requirement and names the remediation available + to a deployment that relied on the old behaviour. Adopting silently is not + adopting; the operator learns the contract from the documentation, not from a + failing login. +* **A tightened default the gateway cannot accommodate is a config-model + finding, reported and escalated — never patched around.** When the gateway's + own configuration model structurally depends on a permissiveness the library + has withdrawn, that is a design signal about the configuration model, and it is + surfaced as such. A local workaround that preserves the old behaviour converts + a legible design problem into a hidden security divergence. +* **A version floor move is audited on the whole delta, not on the feature that + motivated it.** The behaviour-bearing change is rarely the one the bump was + performed for, and it is rarely discoverable from release titles. Every change + in the adopted range is given an explicit verdict — including "checked, no + impact" with the evidence supporting it. An unaudited floor move is not an + adoption; it is an unreviewed change of the gateway's authentication posture. + +== Consequences + +=== Positive + +* The gateway's security posture tracks its security dependency's rather than + drifting behind it, and it does so by default rather than by vigilance. +* The narrowed operator contract is discoverable in documentation before it is + discovered by a failing authentication, giving deployments a remediation path. +* Configuration-model weaknesses surface as findings against the model instead of + being absorbed as local compensations that no future reader can distinguish + from intent. + +=== Negative + +* Adopting a tightened default can break a working deployment at the floor move. + The cost of the tightening is paid by operators, and the decision accepts that + rather than deferring it. +* The whole-delta audit obligation makes every floor move materially more + expensive than a version-string edit, including the large majority of moves + that turn out to carry no behavioural change. + +=== Risks + +* **Structural tests give false assurance.** A test asserting successful + construction cannot detect a default flip, so a green suite must not be read as + a behavioural verdict. The residual mitigation is that the audit's verdict + rests on the changed code and the documented default, with test results cited + only for what they actually cover. +* **An automated dependency bump can bypass the audit entirely.** A bot-authored + one-line version change carries no compatibility review, so a tightened default + can reach the main branch unaudited. The residual exposure is real; the + mitigation is that such a bump leaves the audit obligation outstanding rather + than discharged, and it is discharged retrospectively. +* **"Checked, no impact" can be asserted from a title rather than a diff.** A + verdict that rests on a change's description rather than its content does not + satisfy the audit obligation. + +== Alternatives Considered + +**Restore the permissive default locally and revisit later.** This preserves +every working deployment across the floor move and defers all operator impact, +which is the strongest argument any alternative here has. It is rejected because +it leaves the gateway running a posture the upstream maintainers withdrew for +cause, with the divergence encoded as an unremarkable configuration line. The +deferral has no forcing function — nothing later makes the revisit happen — so +"revisit later" is in practice "never", and the gateway's security posture is +then defined by the oldest default it ever chose to keep. + +**Pin the dependency floor below the hardening release.** This avoids the +behaviour change outright and is honest about doing so, unlike a local revert. It +is rejected because it forfeits every other fix in the release — including the +rest of the hardening — to avoid one behaviour change, and it converts a one-time +adoption cost into an indefinitely growing upgrade gap. + +**Adopt the tightened default but skip the documentation.** This is the cheapest +adoption and keeps the code correct. It is rejected because the operator-visible +contract genuinely narrowed; leaving that undocumented means the first place a +deployment learns about it is a failing authentication with no accompanying +explanation, which is the worst available place to learn it. + +The alternatives fall in one class: each keeps the gateway's _effective_ security +contract different from the one its security dependency now defines, and each +leaves that difference unrecorded. The decision instead makes adoption the +default, documentation the obligation that accompanies it, and escalation — not +compensation — the response when adoption is genuinely blocked. + +== References + +* `doc/configuration.adoc` — the configuration reference where a narrowed + operator contract is recorded when a tightened default changes it. +* `CLAUDE.md` — the project security rules ("all changes must consider security + implications", "use secure defaults") that this ADR applies to the specific + case of an upstream default changing underneath the gateway.