Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
3b18288
docs(plan): add plan 04b and reframe the benchmark convention to k6
OliverWolffGIP Jul 19, 2026
9ecd3b3
feat(benchmarks): add K6BenchmarkConverter and K6ResultPostProcessor
OliverWolffGIP Jul 19, 2026
3f1c480
feat(config): add benchmark aspect routes and the benchmark-realm issuer
OliverWolffGIP Jul 19, 2026
4887112
feat(benchmarks): migrate the benchmark lane to k6 and remove the wrk…
OliverWolffGIP Jul 19, 2026
e182926
feat(benchmarks): add the APISIX comparison compose stack with route-…
OliverWolffGIP Jul 19, 2026
d9f5f10
fix(benchmarks): point APISIX graphql/upload routes at nginx-static f…
OliverWolffGIP Jul 19, 2026
9d17386
feat(config): add jwks.allowed_egress_hosts SSRF egress allowlist
OliverWolffGIP Jul 19, 2026
a57e720
feat(config): add jwks.tls_profile, a neutral trust-profile name boun…
OliverWolffGIP Jul 20, 2026
f1fafbf
fix(benchmarks): use Map.of for additionalData to satisfy the UseMapO…
OliverWolffGIP Jul 20, 2026
befd2f2
fix(edge): clear OpenRewrite exception-usage markers in the dispatch …
OliverWolffGIP Jul 20, 2026
35cd5f8
feat(benchmarks): make the k6 harness target-neutral and add the comp…
OliverWolffGIP Jul 20, 2026
bbd0e21
feat(benchmarks): add xk6-dashboard HTML report as a comparison-only …
OliverWolffGIP Jul 20, 2026
461b22b
docs(benchmarks): document k6 methodology, the wrk-to-k6 swap, and re…
OliverWolffGIP Jul 20, 2026
80621c9
build(deps): bump cui-java-parent from 1.5.1 to 1.5.3
OliverWolffGIP Jul 20, 2026
c7fc139
fix(auth): force eager gateway-validator assembly at startup
OliverWolffGIP Jul 20, 2026
cbd7101
fix(benchmarks): harden k6 summary parsing against malformed and non-…
OliverWolffGIP Jul 20, 2026
48e0fd9
chore(simplify): collapse accidental complexity in plan-04b-comparati…
OliverWolffGIP Jul 20, 2026
68d2bbb
docs(adr): propose ADR-0012 comparison-lane segregation from CI baseline
OliverWolffGIP Jul 20, 2026
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
18 changes: 13 additions & 5 deletions .github/workflows/benchmark.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,13 @@ jobs:
runs-on: ubuntu-latest
# Only run on merged PRs, not just closed ones
if: github.event_name != 'pull_request' || github.event.pull_request.merged == true
# Add timeout to prevent long-running jobs (increased for integration benchmarks)
timeout-minutes: 45
# Add timeout to prevent long-running jobs (increased for integration benchmarks).
# Raised 45 -> 75 for the k6 aspect matrix: the lane went from three wired 60-second runs to
# eight (the six matrix aspects plus the two retained health benchmarks). The dominant
# contributor beyond the old budget is the upload-50MB aspect, which is transfer-bound and
# runs at reduced concurrency, so it does not finish in the same wall time as a request-rate
# run. The five additional runs land inside this cap.
timeout-minutes: 75
permissions:
# Needed to upload artifacts
contents: write
Expand Down Expand Up @@ -78,10 +83,13 @@ jobs:
--previous-pages-dir previous-pages/api-sheriff/benchmarks \
--output-dir "${GITHUB_WORKSPACE}/benchmark-history"

- name: Run Integration Benchmarks with WRK
- name: Run Integration Benchmarks with k6
run: |
# Run WRK-based integration benchmarks with native image
echo "Running WRK integration benchmarks with native Quarkus..."
# Run k6-based integration benchmarks with native image. The -Pbenchmark profile wires
# eight executions: the six matrix aspects (unauth, bearer, http2, graphql, upload-1MB,
# upload-50MB) plus the two retained non-matrix health benchmarks (healthLiveCheck,
# gatewayHealth). The on-demand APISIX comparison lane is deliberately NOT run here.
echo "Running k6 integration benchmarks with native Quarkus..."
./mvnw --no-transfer-progress clean verify -pl benchmarks -Pbenchmark \
-Dbenchmark.history.dir="${GITHUB_WORKSPACE}/benchmark-history/integration"

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
/*
* Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package de.cuioss.sheriff.api.auth;

import javax.net.ssl.SSLContext;


import de.cuioss.sheriff.api.config.model.IssuerConfig;
import de.cuioss.sheriff.api.events.EventType;
import de.cuioss.sheriff.api.events.GatewayException;

import io.quarkus.tls.TlsConfiguration;
import io.quarkus.tls.TlsConfigurationRegistry;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;

/**
* The single mapping seam between {@code gateway.yaml}'s logical
* {@code token_validation.issuers[].jwks.tls_profile} name and the concrete trust material the
* runtime holds.
* <p>
* <strong>This class is the only place in API Sheriff that knows the mapping exists.</strong>
* {@code gateway.yaml} is API Sheriff's own configuration language, so it names a trust profile
* in its own vocabulary — {@code tls_profile: corporate-idp} — and says nothing about how that
* name is bound. Every other collaborator, {@link TokenValidatorProducer} included, deals only in
* that logical name and the resulting {@link SSLContext}. Confining the binding here is what makes
* a user's {@code gateway.yaml} portable: the runtime underneath can change without invalidating
* the operator's configuration, because only this class would have to follow.
*
* <h2>Why the indirection, and why the material stays outside gateway.yaml</h2>
*
* The logical name expresses <em>intent</em> ("verify this IdP against the corporate trust
* anchors"); the deployment supplies the <em>material</em> (the trust store, its password, its
* rotation). That split is deliberate and buys three things:
* <ul>
* <li><strong>Secrets stay out of {@code gateway.yaml}.</strong> A trust-store password never
* appears in the document operators edit, review, and commit.</li>
* <li><strong>No re-implementation of key-store handling.</strong> Loading, formats, reloads,
* and rotation are solved on the runtime side; API Sheriff consumes the result rather than
* growing a second, weaker implementation of the same thing.</li>
* <li><strong>Portability.</strong> The same {@code gateway.yaml} moves between environments
* that bind the profile to different anchors, and survives a change of runtime.</li>
* </ul>
*
* <h2>Boundary rule: config neutral, diagnostics concrete</h2>
*
* {@code gateway.yaml} and its JSON schema never name the runtime. The startup error deliberately
* does — an operator who names an unbound profile needs to be told exactly which knob to set, not
* handed an abstraction. {@link #resolve(IssuerConfig, String)} therefore fails with a message
* naming the concrete runtime key.
*
* <h2>Failure behaviour</h2>
*
* A named-but-unbound profile is a hard startup failure, never a fallback to default trust.
* Silently falling back would turn a misconfigured trust anchor into a JWKS fetch that either
* fails obscurely later or — worse — succeeds against anchors the operator did not intend. This
* matches {@code ConfigProducer}'s existing refuse-to-start posture for invalid configuration.
* <p>
* An issuer that omits {@code tls_profile} never reaches this class: the caller skips resolution
* entirely, so the JWKS client keeps the JVM default trust store with no behavioural change.
*
* @author API Sheriff Team
* @since 1.0
*/
@ApplicationScoped
public class JwksTrustProfileResolver {

private final TlsConfigurationRegistry registry;

/**
* @param registry the runtime's registry of named TLS configurations — the concrete side of
* the mapping this class owns
*/
@Inject
public JwksTrustProfileResolver(TlsConfigurationRegistry registry) {
this.registry = registry;
}

/**
* Resolves a logical trust-profile name to the {@link SSLContext} the JWKS client uses to
* verify the IdP's server certificate.
*
* @param issuer the issuer declaring the profile, for error context
* @param tlsProfile the logical profile name from {@code jwks.tls_profile}
* @return the SSL context carrying the profile's trust anchors, never {@code null}
* @throws GatewayException with {@link EventType#CONFIG_INVALID} when the deployment defines
* no such profile, or when the profile is defined but its trust
* material cannot be turned into an {@link SSLContext}
*/
public SSLContext resolve(IssuerConfig issuer, String tlsProfile) {
TlsConfiguration configuration = registry.get(tlsProfile)
.orElseThrow(() -> new GatewayException(EventType.CONFIG_INVALID,
"Issuer '" + issuer.name() + "' names jwks.tls_profile '" + tlsProfile
+ "' but no such trust profile is configured — define it via "
+ "quarkus.tls." + tlsProfile + ".trust-store.*"));
try {
return configuration.createSSLContext();
// Catching Exception is forced by the contract: TlsConfiguration#createSSLContext
// declares `throws Exception`, so there is no narrower type to catch. Every failure
// mode behind it — unreadable store, wrong password, unsupported format — is the same
// configuration error from the gateway's point of view.
// cui-rewrite:disable InvalidExceptionUsageRecipe
} catch (Exception e) {
throw new GatewayException(EventType.CONFIG_INVALID,
"Issuer '" + issuer.name() + "' names jwks.tls_profile '" + tlsProfile
+ "' but its trust material could not be loaded: " + e.getMessage(), e);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@
import de.cuioss.sheriff.token.commons.transport.HttpJwksLoaderConfig;
import de.cuioss.sheriff.token.validation.TokenValidator;

import io.quarkus.runtime.StartupEvent;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.event.Observes;
import jakarta.enterprise.inject.Produces;
import jakarta.inject.Inject;

Expand All @@ -53,21 +55,51 @@ public class TokenValidatorProducer {
private static final String SOURCE_FILE = "file";

private final GatewayConfig gatewayConfig;
private final JwksTrustProfileResolver trustProfileResolver;

/**
* @param gatewayConfig the bound gateway document carrying the {@code token_validation} block
* @param gatewayConfig the bound gateway document carrying the {@code token_validation}
* block
* @param trustProfileResolver the single seam mapping a logical {@code jwks.tls_profile} name
* to concrete trust anchors
*/
@Inject
public TokenValidatorProducer(GatewayConfig gatewayConfig) {
public TokenValidatorProducer(GatewayConfig gatewayConfig, JwksTrustProfileResolver trustProfileResolver) {
this.gatewayConfig = gatewayConfig;
this.trustProfileResolver = trustProfileResolver;
}

/**
* Forces the validator to be assembled at boot rather than on the first bearer request, so a
* misconfigured issuer — an unresolvable {@code jwks.tls_profile}, a missing JWKS source —
* fails startup instead of surfacing as a runtime rejection once traffic arrives.
* <p>
* Merely observing {@link StartupEvent} with the validator as a parameter is NOT enough: an
* {@code @ApplicationScoped} bean is injected as a lazy client proxy, and ArC does not invoke
* {@link #gatewayTokenValidator()} until the first business method is called on that proxy.
* This method therefore invokes a method on the injected proxy ({@link Object#toString()}) to
* force contextual-instance creation at boot, which runs the full assembly path
* ({@code gatewayTokenValidator} → {@code toValidationIssuer} → {@code applyJwks} →
* {@code toHttpJwksLoaderConfig} → {@code trustProfileResolver.resolve}) and aborts startup on a
* misconfiguration.
*
* @param event the Quarkus startup event
* @param validator the produced gateway validator proxy, whose first method call forces eager
* assembly
*/
void onStartup(@Observes StartupEvent event, @GatewayValidator TokenValidator validator) {
// Invoke a method on the injected proxy to force contextual-instance creation at boot;
// without this the @ApplicationScoped validator stays unassembled until the first request.
validator.toString();
}
Comment thread
OliverWolffGIP marked this conversation as resolved.

/**
* Builds the single shared gateway validator from configured issuers.
*
* @return the gateway {@link TokenValidator}
* @throws GatewayException with {@link EventType#CONFIG_INVALID} when {@code token_validation} is
* absent or an issuer declares no usable JWKS source
* absent, an issuer declares no usable JWKS source, or an issuer names
* a {@code jwks.tls_profile} the deployment does not define
*/
@Produces
@ApplicationScoped
Expand All @@ -83,7 +115,7 @@ public TokenValidator gatewayTokenValidator() {
return TokenValidator.builder().issuerConfigs(issuers).build();
}

private static de.cuioss.sheriff.token.validation.IssuerConfig toValidationIssuer(IssuerConfig issuer) {
private de.cuioss.sheriff.token.validation.IssuerConfig toValidationIssuer(IssuerConfig issuer) {
de.cuioss.sheriff.token.validation.IssuerConfig.IssuerConfigBuilder builder =
de.cuioss.sheriff.token.validation.IssuerConfig.builder().issuerIdentifier(issuer.issuer());
// Audience is optional in the gateway config model (IssuerConfig#audience). token-sheriff
Expand All @@ -100,15 +132,10 @@ private static de.cuioss.sheriff.token.validation.IssuerConfig toValidationIssue
return builder.build();
}

private static void applyJwks(de.cuioss.sheriff.token.validation.IssuerConfig.IssuerConfigBuilder builder,
private void applyJwks(de.cuioss.sheriff.token.validation.IssuerConfig.IssuerConfigBuilder builder,
IssuerConfig issuer, IssuerConfig.Jwks jwks) {
if (SOURCE_HTTP.equals(jwks.source())) {
String url = jwks.url().orElseThrow(() -> new GatewayException(EventType.CONFIG_INVALID,
"Issuer '" + issuer.name() + "' jwks source 'http' declares no url"));
builder.httpJwksLoaderConfig(HttpJwksLoaderConfig.builder()
.issuerIdentifier(issuer.issuer())
.jwksUrl(url)
.build());
builder.httpJwksLoaderConfig(toHttpJwksLoaderConfig(issuer, jwks));
} else if (SOURCE_FILE.equals(jwks.source())) {
String file = jwks.file().orElseThrow(() -> new GatewayException(EventType.CONFIG_INVALID,
"Issuer '" + issuer.name() + "' jwks source 'file' declares no file path"));
Expand All @@ -118,4 +145,44 @@ private static void applyJwks(de.cuioss.sheriff.token.validation.IssuerConfig.Is
"Issuer '" + issuer.name() + "' declares unsupported jwks source '" + jwks.source() + "'");
}
}

/**
* Builds the loader config for an {@code http} JWKS source, applying the issuer's
* {@code allowed_egress_hosts} allowlist on top of token-sheriff's SSRF egress guard and
* the trust anchors its {@code tls_profile} names.
* <p>
* <strong>Secure by default.</strong> When {@code allowed_egress_hosts} is absent or
* empty this method calls no egress builder method at all, so the built config keeps
* {@link de.cuioss.sheriff.token.commons.transport.EgressPolicy#secureDefault()} — a
* JWKS URL resolving to a loopback, link-local, site-local, any-local, multicast, or
* unique-local address is refused. Each configured host is passed to
* {@link HttpJwksLoaderConfig.HttpJwksLoaderConfigBuilder#allowedEgressHost(String)},
* which exempts that single host and nothing else; the allowlist is host-exact, never
* a wildcard or a suffix match. This is the narrow widening the threat model's GW-05
* and BFF-07 prescribe for a trusted IdP that lives on a private network.
* <p>
* <strong>Default trust unless a profile is named.</strong> When {@code tls_profile} is
* absent no SSL context is set, so the JWKS client keeps the JVM's default trust store —
* the correct behaviour for an IdP presenting a publicly-trusted certificate. When a
* profile IS named, {@link JwksTrustProfileResolver} maps it to the deployment's trust
* anchors; an unresolvable name fails startup rather than falling back to default trust.
*
* @param issuer the gateway issuer entry, for the identifier and error context
* @param jwks the issuer's {@code http} JWKS block
* @return the loader config carrying the resolved egress policy and trust anchors
* @throws GatewayException with {@link EventType#CONFIG_INVALID} when the block
* declares no url, or names an unresolvable {@code tls_profile}
*/
HttpJwksLoaderConfig toHttpJwksLoaderConfig(IssuerConfig issuer, IssuerConfig.Jwks jwks) {
String url = jwks.url().orElseThrow(() -> new GatewayException(EventType.CONFIG_INVALID,
"Issuer '" + issuer.name() + "' jwks source 'http' declares no url"));
HttpJwksLoaderConfig.HttpJwksLoaderConfigBuilder builder = HttpJwksLoaderConfig.builder()
.issuerIdentifier(issuer.issuer())
.jwksUrl(url);
for (String host : jwks.allowedEgressHosts()) {
builder.allowedEgressHost(host);
}
jwks.tlsProfile().ifPresent(profile -> builder.sslContext(trustProfileResolver.resolve(issuer, profile)));
return builder.build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
*/
package de.cuioss.sheriff.api.config.model;

import java.util.List;
import java.util.Objects;
import java.util.Optional;

Expand Down Expand Up @@ -47,23 +48,45 @@ public record IssuerConfig(String name, String issuer, Optional<String> audience
/**
* Key material for signature verification.
*
* @param source the key source ({@code http} / {@code file}), mandatory
* @param url the JWKS URL (for {@code source: http}), empty otherwise
* @param file the JWKS file path (for {@code source: file}), empty otherwise
* @param source the key source ({@code http} / {@code file}), mandatory
* @param url the JWKS URL (for {@code source: http}), empty otherwise
* @param file the JWKS file path (for {@code source: file}), empty
* otherwise
* @param allowedEgressHosts the SSRF egress allowlist for {@code source: http}, empty
* when omitted. An empty list keeps token-sheriff's
* {@code EgressPolicy.secureDefault()}, which refuses a JWKS
* URL resolving to a loopback, link-local, site-local,
* any-local, multicast, or unique-local address. Each entry
* exempts exactly one trusted IdP host from that check
* (threat model GW-05 / BFF-07); there is no wildcard form.
* @param tlsProfile the name of the logical trust profile the JWKS client uses
* to verify the IdP's server certificate, empty when omitted.
* This is a name in API Sheriff's own vocabulary, deliberately
* carrying no trust material and no runtime detail: the
* deployment binds the name to concrete trust anchors, and one
* mapping component resolves it. Omitted means the JWKS client
* uses the JVM's default trust store, which is correct for a
* public-CA IdP. Name a profile when the IdP presents a
* certificate from an internal or corporate CA.
* @author API Sheriff Team
* @since 1.0
*/
@Builder
public record Jwks(String source, Optional<String> url, Optional<String> file) {
public record Jwks(String source, Optional<String> url, Optional<String> file,
List<String> allowedEgressHosts, Optional<String> tlsProfile) {

/**
* Canonical constructor requiring {@code source} and normalizing absent
* optionals to {@link Optional#empty()}.
* Canonical constructor requiring {@code source}, normalizing absent optionals to
* {@link Optional#empty()}, and defensively copying the egress allowlist — an
* absent list normalizes to {@link List#of()}, which preserves the secure egress
* default rather than widening it.
*/
public Jwks {
Objects.requireNonNull(source, "source");
url = Objects.requireNonNullElse(url, Optional.empty());
file = Objects.requireNonNullElse(file, Optional.empty());
allowedEgressHosts = allowedEgressHosts == null ? List.of() : List.copyOf(allowedEgressHosts);
tlsProfile = Objects.requireNonNullElse(tlsProfile, Optional.empty());
}
}
}
Loading
Loading