Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
@@ -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 `<dependencyManagement>`
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 `<dependencyManagement>` 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.
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 6 additions & 1 deletion doc/configuration.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 7 additions & 4 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -81,10 +81,13 @@
<scope>import</scope>
</dependency>
<!-- Token Sheriff BOM: supplies token-sheriff-client / -client-quarkus / -validation-quarkus
versions. Declared after quarkus-bom on purpose: quarkus-bom is first, so it wins the
smallrye-config convergence to Quarkus 3.37.2's native 3.17.2. (cui-java-parent otherwise
bumps smallrye-config to 3.18.0; the token-sheriff modules pull smallrye-config at compile
scope, and the resulting 3.17.2/3.18.0 split breaks Quarkus config-mapping augmentation.) -->
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. -->
<dependency>
<groupId>de.cuioss.sheriff.token</groupId>
<artifactId>token-sheriff-bom</artifactId>
Expand Down
Loading