extractBearerToken(PipelineRequest request) {
+ return request.firstHeader("Authorization")
+ .filter(value -> value.regionMatches(true, 0, BEARER_PREFIX, 0, BEARER_PREFIX.length()))
+ .map(value -> value.substring(BEARER_PREFIX.length()).strip())
+ .filter(token -> !token.isEmpty());
+ }
+
+ private static GatewayException unauthorized(PipelineRequest request, EventType eventType, String detail) {
+ request.responseHeaders().put("WWW-Authenticate", "Bearer");
+ return new GatewayException(eventType, detail);
+ }
+
+ private static GatewayException unauthorizedFromValidation(PipelineRequest request,
+ TokenValidationException validationFailure) {
+ request.responseHeaders().put("WWW-Authenticate", "Bearer");
+ return new GatewayException(EventType.TOKEN_INVALID, "Bearer token rejected by validation", validationFailure);
+ }
+
+ private static RouteRuntime requireSelectedRoute(PipelineRequest request) {
+ RouteRuntime route = request.selectedRoute();
+ if (route == null) {
+ throw new IllegalStateException("Authentication requires the route selected at stage 2");
+ }
+ return route;
+ }
+
+ private static String requireCanonicalPath(PipelineRequest request) {
+ String canonicalPath = request.canonicalPath();
+ if (canonicalPath == null) {
+ throw new IllegalStateException("Authentication requires the canonical path resolved at stage 1");
+ }
+ return canonicalPath;
+ }
+}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/auth/GatewayValidator.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/auth/GatewayValidator.java
new file mode 100644
index 0000000..746787b
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/auth/GatewayValidator.java
@@ -0,0 +1,49 @@
+/*
+ * 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 static java.lang.annotation.ElementType.FIELD;
+import static java.lang.annotation.ElementType.METHOD;
+import static java.lang.annotation.ElementType.PARAMETER;
+import static java.lang.annotation.ElementType.TYPE;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import java.lang.annotation.Documented;
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+
+import jakarta.inject.Qualifier;
+
+/**
+ * CDI qualifier distinguishing the gateway's own {@code TokenValidator} — the single shared
+ * validator {@link TokenValidatorProducer} builds from the {@code token_validation} block of
+ * {@code gateway.yaml} — from the unqualified validator the {@code token-sheriff-validation-quarkus}
+ * extension produces from its {@code cui.jwt.*} property surface.
+ *
+ * The gateway configures issuers through its own YAML model, not the extension's MicroProfile
+ * properties, so the two producers must coexist without an ambiguous-dependency clash. The gateway
+ * request pipeline injects {@code @GatewayValidator TokenValidator}; the extension's own beans keep
+ * their unqualified default.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+@Qualifier
+@Documented
+@Retention(RUNTIME)
+@Target({METHOD, FIELD, PARAMETER, TYPE})
+public @interface GatewayValidator {
+}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/auth/TokenValidatorProducer.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/auth/TokenValidatorProducer.java
new file mode 100644
index 0000000..ae17863
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/auth/TokenValidatorProducer.java
@@ -0,0 +1,121 @@
+/*
+ * 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 java.util.ArrayList;
+import java.util.List;
+
+
+import de.cuioss.sheriff.api.config.model.GatewayConfig;
+import de.cuioss.sheriff.api.config.model.IssuerConfig;
+import de.cuioss.sheriff.api.config.model.TokenValidationConfig;
+import de.cuioss.sheriff.api.events.EventType;
+import de.cuioss.sheriff.api.events.GatewayException;
+import de.cuioss.sheriff.token.commons.transport.HttpJwksLoaderConfig;
+import de.cuioss.sheriff.token.validation.TokenValidator;
+
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.enterprise.inject.Produces;
+import jakarta.inject.Inject;
+
+/**
+ * CDI producer of the gateway's single shared {@link TokenValidator}, built once from the
+ * {@code token_validation} block of {@code gateway.yaml}.
+ *
+ * The produced validator carries the {@link GatewayValidator} qualifier so it coexists with the
+ * unqualified validator the {@code token-sheriff-validation-quarkus} extension produces from its
+ * {@code cui.jwt.*} property surface — the gateway drives issuers from its own YAML model, not the
+ * extension's properties. Each gateway {@link IssuerConfig} maps to a token-sheriff
+ * {@link de.cuioss.sheriff.token.validation.IssuerConfig}: an {@code http} JWKS source becomes an
+ * {@link HttpJwksLoaderConfig}, a {@code file} source becomes a JWKS file path. Validation is fully
+ * offline once the key material has loaded.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+@ApplicationScoped
+public class TokenValidatorProducer {
+
+ private static final String SOURCE_HTTP = "http";
+ private static final String SOURCE_FILE = "file";
+
+ private final GatewayConfig gatewayConfig;
+
+ /**
+ * @param gatewayConfig the bound gateway document carrying the {@code token_validation} block
+ */
+ @Inject
+ public TokenValidatorProducer(GatewayConfig gatewayConfig) {
+ this.gatewayConfig = gatewayConfig;
+ }
+
+ /**
+ * 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
+ */
+ @Produces
+ @ApplicationScoped
+ @GatewayValidator
+ public TokenValidator gatewayTokenValidator() {
+ TokenValidationConfig config = gatewayConfig.tokenValidation()
+ .orElseThrow(() -> new GatewayException(EventType.CONFIG_INVALID,
+ "token_validation is required to build the bearer-token validator"));
+ List issuers = new ArrayList<>();
+ for (IssuerConfig issuer : config.issuers()) {
+ issuers.add(toValidationIssuer(issuer));
+ }
+ return TokenValidator.builder().issuerConfigs(issuers).build();
+ }
+
+ private static 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
+ // requires an explicit choice at build time — either a non-empty expected audience OR an
+ // explicit opt-out — so an issuer that configures no audience must disable audience
+ // validation; otherwise IssuerConfig.build() throws and the (lazily created) validator bean
+ // fails on the first bearer request instead of validating the token.
+ issuer.audience().ifPresentOrElse(
+ builder::expectedAudience,
+ () -> builder.audienceValidationDisabled(true));
+ IssuerConfig.Jwks jwks = issuer.jwks().orElseThrow(() -> new GatewayException(EventType.CONFIG_INVALID,
+ "Issuer '" + issuer.name() + "' declares no jwks source"));
+ applyJwks(builder, issuer, jwks);
+ return builder.build();
+ }
+
+ private static 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());
+ } 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"));
+ builder.jwksFilePath(file);
+ } else {
+ throw new GatewayException(EventType.CONFIG_INVALID,
+ "Issuer '" + issuer.name() + "' declares unsupported jwks source '" + jwks.source() + "'");
+ }
+ }
+}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/auth/package-info.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/auth/package-info.java
new file mode 100644
index 0000000..5a2c32d
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/auth/package-info.java
@@ -0,0 +1,38 @@
+/*
+ * 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.
+ */
+/**
+ * Stage 4 — offline bearer-token validation and its CDI wiring.
+ *
+ * {@link de.cuioss.sheriff.api.auth.AuthenticationStage} enforces each route's effective auth
+ * posture on the agnostic {@code PipelineRequest}, validating {@code Bearer} tokens offline through
+ * the shared {@link de.cuioss.sheriff.token.validation.TokenValidator}.
+ * {@link de.cuioss.sheriff.api.auth.TokenValidatorProducer} produces that validator from the
+ * gateway's {@code token_validation} config, tagged with the
+ * {@link de.cuioss.sheriff.api.auth.GatewayValidator} qualifier so it coexists with the validator
+ * shipped by {@code token-sheriff-validation-quarkus}.
+ *
+ * Framework edge. This package holds CDI wiring ({@code jakarta.enterprise} /
+ * {@code jakarta.inject}) and is therefore part of the framework edge, deliberately outside the
+ * ADR-0005 framework-agnostic arch-gate rule set (which covers {@code config.model},
+ * {@code config.validation}, {@code events}, {@code forward}, and {@code pipeline} only).
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+@NullMarked
+package de.cuioss.sheriff.api.auth;
+
+import org.jspecify.annotations.NullMarked;
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/ConfigLogMessages.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/ConfigLogMessages.java
index 7ebf968..45df7b2 100644
--- a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/ConfigLogMessages.java
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/ConfigLogMessages.java
@@ -17,6 +17,7 @@
import de.cuioss.tools.logging.LogRecord;
import de.cuioss.tools.logging.LogRecordModel;
+
import lombok.experimental.UtilityClass;
/**
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/RouteTableBuilder.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/RouteTableBuilder.java
index 6fea4c9..17a5cc0 100644
--- a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/RouteTableBuilder.java
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/RouteTableBuilder.java
@@ -23,9 +23,11 @@
import java.util.Objects;
import java.util.Optional;
+
import de.cuioss.sheriff.api.config.model.AnchorConfig;
import de.cuioss.sheriff.api.config.model.AuthConfig;
import de.cuioss.sheriff.api.config.model.EndpointConfig;
+import de.cuioss.sheriff.api.config.model.ForwardConfig;
import de.cuioss.sheriff.api.config.model.GatewayConfig;
import de.cuioss.sheriff.api.config.model.HttpMethod;
import de.cuioss.sheriff.api.config.model.Protocol;
@@ -48,8 +50,9 @@
* endpoints contribute no rows — orders them by descending normalized
* {@code path_prefix} length (most specific first), and
* materializes each route's effective auth, effective {@code allowed_methods},
- * effective {@code security_filter} / {@code security_headers}, and effective retry
- * / not-modified toggles into a {@link ResolvedRoute}. The inheritance chains
+ * effective {@code security_filter} / {@code security_headers}, effective retry
+ * / not-modified toggles, and the effective deny-by-default {@code forward}
+ * allowlist into a {@link ResolvedRoute}. The inheritance chains
* (gateway defaults → anchor → endpoint → route, wholesale replacement at every
* step — ADR-0007) are resolved here, once, so the request pipeline never
* re-implements them and never consults an anchor. The effective posture of each
@@ -150,6 +153,7 @@ private static ResolvedRoute resolveRoute(GatewayConfig gateway, RouteConfig rou
.flatMap(UpstreamConfig::notModified)
.flatMap(UpstreamConfig.NotModified::enabled)
.orElse(defaults.notModifiedEnabled());
+ ForwardConfig effectiveForward = route.forward().orElseGet(() -> ForwardConfig.builder().build());
ResolvedRoute resolved = ResolvedRoute.builder()
.id(route.id())
.protocol(route.protocol().orElse(Protocol.HTTP))
@@ -162,6 +166,7 @@ private static ResolvedRoute resolveRoute(GatewayConfig gateway, RouteConfig rou
.retryEnabled(retryEnabled)
.notModifiedEnabled(notModifiedEnabled)
.upstream(upstream)
+ .effectiveForward(effectiveForward)
.build();
logPosture(resolved);
return resolved;
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/load/ConfigLoader.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/load/ConfigLoader.java
index 2c17e54..bd0213a 100644
--- a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/load/ConfigLoader.java
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/load/ConfigLoader.java
@@ -31,6 +31,11 @@
import java.util.regex.Pattern;
import java.util.stream.Stream;
+
+import de.cuioss.sheriff.api.config.model.EndpointConfig;
+import de.cuioss.sheriff.api.config.model.GatewayConfig;
+import de.cuioss.sheriff.api.config.model.UpstreamDefaultsConfig;
+
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.StreamReadConstraints;
import com.fasterxml.jackson.databind.DeserializationContext;
@@ -50,14 +55,10 @@
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
import com.fasterxml.jackson.dataformat.yaml.YAMLMapper;
import com.fasterxml.jackson.datatype.jdk8.Jdk8Module;
-
import com.networknt.schema.JsonSchema;
import com.networknt.schema.JsonSchemaFactory;
import com.networknt.schema.SpecVersion;
import com.networknt.schema.ValidationMessage;
-import de.cuioss.sheriff.api.config.model.EndpointConfig;
-import de.cuioss.sheriff.api.config.model.GatewayConfig;
-import de.cuioss.sheriff.api.config.model.UpstreamDefaultsConfig;
import org.jspecify.annotations.Nullable;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/ResolvedRoute.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/ResolvedRoute.java
index d303abb..1309d74 100644
--- a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/ResolvedRoute.java
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/ResolvedRoute.java
@@ -53,6 +53,9 @@
* @param notModifiedEnabled the materialized HTTP-304 not-modified toggle
* @param upstream the resolved upstream target for the route's
* endpoint (mandatory)
+ * @param effectiveForward the materialized, deny-by-default {@code forward}
+ * allowlist consumed by stage 5; an empty
+ * {@link ForwardConfig} when the route declares none
* @author API Sheriff Team
* @since 1.0
*/
@@ -60,12 +63,13 @@
public record ResolvedRoute(String id, Protocol protocol, Optional anchor, MatchConfig match,
AuthConfig effectiveAuth, List effectiveAllowedMethods,
Optional effectiveSecurityFilter, Optional effectiveSecurityHeaders,
-boolean retryEnabled, boolean notModifiedEnabled, ResolvedUpstream upstream) {
+boolean retryEnabled, boolean notModifiedEnabled, ResolvedUpstream upstream, ForwardConfig effectiveForward) {
/**
* Canonical constructor requiring the mandatory components, defensively copying
- * {@code effectiveAllowedMethods}, normalizing absent optionals, and defaulting
- * an absent {@code protocol} to {@link Protocol#HTTP}.
+ * {@code effectiveAllowedMethods}, normalizing absent optionals, defaulting an
+ * absent {@code protocol} to {@link Protocol#HTTP}, and defaulting an absent
+ * {@code effectiveForward} to a deny-by-default empty {@link ForwardConfig}.
*/
public ResolvedRoute {
Objects.requireNonNull(id, "id");
@@ -77,6 +81,7 @@ public record ResolvedRoute(String id, Protocol protocol, Optional ancho
effectiveSecurityFilter = Objects.requireNonNullElse(effectiveSecurityFilter, Optional.empty());
effectiveSecurityHeaders = Objects.requireNonNullElse(effectiveSecurityHeaders, Optional.empty());
Objects.requireNonNull(upstream, "upstream");
+ effectiveForward = effectiveForward == null ? ForwardConfig.builder().build() : effectiveForward;
}
/**
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/topology/TopologyResolver.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/topology/TopologyResolver.java
index 6f0e0bd..d2f9ae7 100644
--- a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/topology/TopologyResolver.java
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/topology/TopologyResolver.java
@@ -31,10 +31,12 @@
import java.util.Properties;
import java.util.regex.Pattern;
+
import de.cuioss.sheriff.api.config.load.EnvSecretResolver;
import de.cuioss.sheriff.api.config.model.EndpointConfig;
import de.cuioss.sheriff.api.config.model.ResolvedTopology;
import de.cuioss.sheriff.api.config.model.ResolvedUpstream;
+
import org.jspecify.annotations.Nullable;
/**
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/validation/ConfigValidator.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/validation/ConfigValidator.java
index fe0e7cc..019bb7e 100644
--- a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/validation/ConfigValidator.java
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/validation/ConfigValidator.java
@@ -28,6 +28,7 @@
import java.util.Optional;
import java.util.Set;
+
import de.cuioss.sheriff.api.config.ConfigLogMessages;
import de.cuioss.sheriff.api.config.RouteTableBuilder;
import de.cuioss.sheriff.api.config.load.ConfigError;
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/validation/rule/ValidationRule.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/validation/rule/ValidationRule.java
index f170baa..1fcde6d 100644
--- a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/validation/rule/ValidationRule.java
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/validation/rule/ValidationRule.java
@@ -17,6 +17,7 @@
import java.util.List;
+
import de.cuioss.sheriff.api.config.load.ConfigError;
import de.cuioss.sheriff.api.config.model.EndpointConfig;
import de.cuioss.sheriff.api.config.model.GatewayConfig;
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/edge/DispatchStage.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/edge/DispatchStage.java
new file mode 100644
index 0000000..4b3e5bc
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/edge/DispatchStage.java
@@ -0,0 +1,339 @@
+/*
+ * 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.edge;
+
+import java.util.Map;
+import java.util.Objects;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.BooleanSupplier;
+import java.util.function.LongConsumer;
+import java.util.function.LongSupplier;
+
+
+import de.cuioss.sheriff.api.config.model.HttpMethod;
+import de.cuioss.sheriff.api.config.model.ResolvedUpstream;
+import de.cuioss.sheriff.api.events.EventType;
+import de.cuioss.sheriff.api.events.GatewayException;
+import de.cuioss.sheriff.api.routing.RouteRuntime;
+
+import io.smallrye.faulttolerance.api.Guard;
+import io.vertx.core.Future;
+import io.vertx.core.Handler;
+import io.vertx.core.buffer.Buffer;
+import io.vertx.core.http.HttpClientResponse;
+import io.vertx.core.http.RequestOptions;
+import io.vertx.core.streams.ReadStream;
+import org.jspecify.annotations.Nullable;
+
+/**
+ * Stage 6 — upstream dispatch over the route's shared Vert.x {@code HttpClient}, invoked through
+ * that route's SmallRye Fault-Tolerance guard.
+ *
+ * The request body is streamed, never buffered: it flows through a
+ * {@link ByteCappedBodyStream} that forwards each chunk to the upstream request as it arrives and
+ * enforces the {@code max_body_bytes} ceiling with a running counter. A mid-stream breach ABORTS
+ * the in-flight upstream call (Vert.x {@link HttpClientRequest#reset()}) and surfaces
+ * {@link EventType#PARAMETER_LIMIT_EXCEEDED} (400). The upstream body is never
+ * materialized into an {@code HttpResult} (ADR-0006/0008): the returned
+ * {@link HttpClientResponse} is a live {@link ReadStream} whose body {@link ResponseStage} streams
+ * back with backpressure.
+ *
+ * The dispatch is awaited synchronously on the caller's virtual thread inside the guard so the
+ * breaker observes each call's success / failure / timeout. Guard failures are mapped to the error
+ * contract by {@link UpstreamFailureMapper}; a body-cap breach propagates its own
+ * {@link GatewayException} unchanged.
+ *
+ * Stream-aware retry gating. When the route enables SmallRye retry, the guarded
+ * lambda may be re-invoked after a failure. A streamed request cannot be safely replayed once any
+ * body byte has crossed to the upstream, and a non-idempotent verb must never be re-sent — so every
+ * retry re-entry (never the first attempt) is vetted by a {@link StreamAwareRetryGate}
+ * against the request method and the running body-bytes-sent count. A disallowed re-entry is aborted
+ * by re-raising the first attempt's failure as a mapped {@link GatewayException}, which the guard's
+ * {@code abortOn(GatewayException.class)} contract turns into an immediate abort — no duplicate
+ * upstream request is ever issued.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+public final class DispatchStage {
+
+ /** Bounds the cause-chain walk so a self-referential cause cycle cannot spin forever. */
+ private static final int MAX_CAUSE_DEPTH = 16;
+
+ private final long maxBodyBytes;
+ private final UpstreamFailureMapper failureMapper;
+
+ /**
+ * @param maxBodyBytes the streaming request-body ceiling in bytes ({@code max_body_bytes})
+ * @param failureMapper the mapper turning guarded-dispatch failures into the error contract
+ */
+ public DispatchStage(long maxBodyBytes, UpstreamFailureMapper failureMapper) {
+ this.maxBodyBytes = maxBodyBytes;
+ this.failureMapper = Objects.requireNonNull(failureMapper, "failureMapper");
+ }
+
+ /**
+ * Builds the upstream request URI (path + query) — the resolved upstream base path, the request
+ * path remainder, and the allow-listed raw query appended verbatim.
+ *
+ * @param upstream the resolved upstream target
+ * @param pathRemainder the request path remainder after the route prefix is stripped
+ * @param rawQuery the raw query string including its leading {@code ?}, or empty when none
+ * @return the upstream request URI
+ */
+ public static String upstreamRequestUri(ResolvedUpstream upstream, String pathRemainder, String rawQuery) {
+ Objects.requireNonNull(upstream, "upstream");
+ Objects.requireNonNull(pathRemainder, "pathRemainder");
+ String path = stripTrailingSlash(upstream.basePath()) + pathRemainder;
+ return rawQuery == null || rawQuery.isEmpty() ? path : path + rawQuery;
+ }
+
+ /**
+ * Dispatches the request to the route's upstream, streaming the (byte-capped) request body and
+ * returning the response whose body is not yet consumed. Every retry re-entry is vetted by a
+ * {@link StreamAwareRetryGate} so a non-idempotent method or a request that has already streamed
+ * a body byte is never re-sent (see the class javadoc).
+ *
+ * @param method the request method — used both to build the upstream request and to
+ * gate retry re-entries for idempotency
+ * @param route the resolved route runtime holding the shared client and guard
+ * @param requestUri the upstream request URI (see {@link #upstreamRequestUri})
+ * @param forwardHeaders the deny-by-default header set computed by stage 5
+ * @param requestBody the inbound request body as a live read stream
+ * @return the upstream response (body still streaming)
+ * @throws GatewayException carrying the mapped error-contract event on any dispatch failure
+ */
+ public HttpClientResponse dispatch(RouteRuntime route, HttpMethod method, String requestUri,
+ Map forwardHeaders, ReadStream requestBody) {
+ Objects.requireNonNull(route, "route");
+ Objects.requireNonNull(method, "method");
+ Objects.requireNonNull(requestUri, "requestUri");
+ Objects.requireNonNull(forwardHeaders, "forwardHeaders");
+ Objects.requireNonNull(requestBody, "requestBody");
+ AtomicLong bytesSent = new AtomicLong();
+ AtomicBoolean bodyStreamSubscribed = new AtomicBoolean();
+ StreamAwareRetryGate retryGate = new StreamAwareRetryGate(route.isRetryEnabled());
+ io.vertx.core.http.HttpMethod upstreamMethod = io.vertx.core.http.HttpMethod.valueOf(method.name());
+ return guardedDispatch(route.getResilienceGuard(), retryGate, method, bytesSent::get,
+ bodyStreamSubscribed::get,
+ () -> awaitDispatch(route, upstreamMethod, requestUri, forwardHeaders, requestBody, bytesSent,
+ bodyStreamSubscribed));
+ }
+
+ /**
+ * Runs {@code attempt} through the route's resilience {@code guard}, vetting every retry
+ * re-entry: the first attempt always proceeds, but each subsequent re-entry is aborted — by
+ * re-raising the prior failure as a mapped {@link GatewayException} (which the guard's
+ * {@code abortOn(GatewayException.class)} contract honours) — whenever either the
+ * {@code retryGate} refuses a retry for {@code method} at the current {@code bytesSent} count,
+ * or {@code bodyStreamConsumed} reports that the one-shot request-body stream was
+ * already subscribed on a prior attempt. The inbound body {@link ReadStream} is single-use:
+ * re-attaching an already-subscribed stream to a fresh upstream request would silently stall
+ * waiting for events that already fired on the first attempt, so such a re-entry must fail
+ * explicitly rather than hang — even in the {@code bytesSent == 0} case, since the stream is
+ * subscribed the instant the first attempt reaches {@code request.send(...)}, before any byte
+ * crosses. Package-private so the retry-gating decision can be exercised without a live upstream.
+ */
+ HttpClientResponse guardedDispatch(Guard guard, StreamAwareRetryGate retryGate, HttpMethod method,
+ LongSupplier bytesSent, BooleanSupplier bodyStreamConsumed, Callable attempt) {
+ AtomicInteger attemptIndex = new AtomicInteger();
+ AtomicReference priorFailure = new AtomicReference<>();
+ try {
+ return guard.call(() -> {
+ if (attemptIndex.getAndIncrement() > 0
+ && (bodyStreamConsumed.getAsBoolean()
+ || !retryGate.allowsRetry(method, bytesSent.getAsLong()))) {
+ throw failureMapper.toGatewayException(priorFailure.get());
+ }
+ try {
+ return attempt.call();
+ } catch (ExecutionException executionFailure) {
+ // awaitDispatch blocks on CompletableFuture#get, which wraps the real cause (e.g. a
+ // client-side body-cap GatewayException from ByteCappedBodyStream) in an
+ // ExecutionException. Unwrap it so the guard's skipOn(GatewayException.class) and
+ // abortOn(GatewayException.class) rules see the actual GatewayException rather than
+ // the wrapper — otherwise a client-caused rejection is miscounted as an upstream
+ // failure and can trip the circuit breaker for reasons unrelated to upstream health.
+ Throwable cause = executionFailure.getCause();
+ Exception unwrapped = cause instanceof Exception exception ? exception : executionFailure;
+ priorFailure.set(unwrapped);
+ throw unwrapped;
+ }
+ }, HttpClientResponse.class);
+ } catch (GatewayException direct) {
+ throw direct;
+ } /*~~(TODO: Catch specific not Exception. Suppress: // cui-rewrite:disable InvalidExceptionUsageRecipe)~~>*/ catch (Exception guarded) {
+ GatewayException breach = extractGatewayException(guarded);
+ if (breach != null) {
+ throw breach;
+ }
+ throw failureMapper.toGatewayException(guarded);
+ }
+ }
+
+ private HttpClientResponse awaitDispatch(RouteRuntime route, io.vertx.core.http.HttpMethod method,
+ String requestUri, Map forwardHeaders, ReadStream requestBody,
+ AtomicLong bytesSent, AtomicBoolean bodyStreamSubscribed) throws Exception {
+ ResolvedUpstream upstream = route.getUpstream();
+ RequestOptions options = new RequestOptions()
+ .setMethod(method)
+ .setHost(upstream.host())
+ .setPort(upstream.port())
+ .setSsl("https".equalsIgnoreCase(upstream.scheme()))
+ .setURI(requestUri);
+ Future response = route.getHttpClient().request(options)
+ .compose(request -> {
+ forwardHeaders.forEach(request::putHeader);
+ // The one-shot inbound body stream is subscribed the instant it is handed to
+ // request.send(...); mark it so a retry re-entry never re-attaches the consumed
+ // stream (which would stall) — see guardedDispatch's bodyStreamConsumed gate.
+ bodyStreamSubscribed.set(true);
+ return request.send(new ByteCappedBodyStream(requestBody, maxBodyBytes, request::reset,
+ bytesSent::addAndGet));
+ })
+ .map(received -> {
+ // Pause the upstream body the instant its head arrives, on the client's own
+ // event-loop context and before any body chunk is dispatched. ResponseStage#relay
+ // is deferred onto the server request's event loop (GatewayEdgeRoute), so without
+ // this pause a small upstream response can fully arrive and end before the deferred
+ // pipeTo subscribes — Vert.x then fails the pipe with "Response already ended". The
+ // pipe re-enables the stream when it subscribes.
+ received.pause();
+ return received;
+ });
+ return response.toCompletionStage().toCompletableFuture().get();
+ }
+
+ private static @Nullable GatewayException extractGatewayException(Throwable failure) {
+ Throwable current = failure;
+ for (int depth = 0; current != null && depth < MAX_CAUSE_DEPTH; depth++) {
+ if (current instanceof GatewayException gatewayException) {
+ return gatewayException;
+ }
+ Throwable cause = current.getCause();
+ if (cause == current) {
+ break;
+ }
+ current = cause;
+ }
+ return null;
+ }
+
+ private static String stripTrailingSlash(String value) {
+ return value.endsWith("/") ? value.substring(0, value.length() - 1) : value;
+ }
+
+ /**
+ * A {@link ReadStream} decorator that forwards each request-body chunk to the upstream as it
+ * arrives — never accumulating the body — while counting bytes against a ceiling. On breach it
+ * aborts the in-flight upstream request and fails the stream with a
+ * {@link EventType#PARAMETER_LIMIT_EXCEEDED} {@link GatewayException}.
+ */
+ static final class ByteCappedBodyStream implements ReadStream {
+
+ private final ReadStream delegate;
+ private final long maxBytes;
+ private final Runnable abortAction;
+ private final LongConsumer bytesForwarded;
+ private long bytesSeen;
+ private @Nullable Handler dataHandler;
+ private @Nullable Handler failureHandler;
+ private boolean aborted;
+
+ ByteCappedBodyStream(ReadStream delegate, long maxBytes, Runnable abortAction) {
+ this(delegate, maxBytes, abortAction, length -> {
+ });
+ }
+
+ ByteCappedBodyStream(ReadStream delegate, long maxBytes, Runnable abortAction,
+ LongConsumer bytesForwarded) {
+ this.delegate = Objects.requireNonNull(delegate, "delegate");
+ this.maxBytes = maxBytes;
+ this.abortAction = Objects.requireNonNull(abortAction, "abortAction");
+ this.bytesForwarded = Objects.requireNonNull(bytesForwarded, "bytesForwarded");
+ delegate.exceptionHandler(this::propagateFailure);
+ delegate.handler(this::onChunk);
+ }
+
+ private void onChunk(Buffer chunk) {
+ if (aborted) {
+ return;
+ }
+ bytesSeen += chunk.length();
+ if (bytesSeen > maxBytes) {
+ aborted = true;
+ delegate.pause();
+ abortAction.run();
+ propagateFailure(new GatewayException(EventType.PARAMETER_LIMIT_EXCEEDED,
+ "Request body exceeded max_body_bytes=" + maxBytes));
+ return;
+ }
+ // The chunk cleared the cap and is about to cross to the upstream — record it so the
+ // stream-aware retry gate can see that a body byte has been sent on this attempt.
+ bytesForwarded.accept(chunk.length());
+ if (dataHandler != null) {
+ dataHandler.handle(chunk);
+ }
+ }
+
+ private void propagateFailure(Throwable failure) {
+ if (failureHandler != null) {
+ failureHandler.handle(failure);
+ }
+ }
+
+ @Override
+ public ReadStream handler(@Nullable Handler handler) {
+ this.dataHandler = handler;
+ return this;
+ }
+
+ @Override
+ public ReadStream exceptionHandler(@Nullable Handler handler) {
+ this.failureHandler = handler;
+ return this;
+ }
+
+ @Override
+ public ReadStream pause() {
+ delegate.pause();
+ return this;
+ }
+
+ @Override
+ public ReadStream resume() {
+ delegate.resume();
+ return this;
+ }
+
+ @Override
+ public ReadStream fetch(long amount) {
+ delegate.fetch(amount);
+ return this;
+ }
+
+ @Override
+ public ReadStream endHandler(@Nullable Handler endHandler) {
+ delegate.endHandler(endHandler);
+ return this;
+ }
+ }
+}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/edge/EdgeHardeningOptions.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/edge/EdgeHardeningOptions.java
new file mode 100644
index 0000000..f827176
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/edge/EdgeHardeningOptions.java
@@ -0,0 +1,99 @@
+/*
+ * 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.edge;
+
+import java.util.concurrent.TimeUnit;
+
+import io.quarkus.vertx.http.HttpServerOptionsCustomizer;
+import io.vertx.core.http.HttpServerOptions;
+import jakarta.enterprise.context.ApplicationScoped;
+
+/**
+ * Edge hardening for the public data-plane HTTP server. It is BOTH the carrier of the
+ * gateway's inbound-transport bounds AND the {@link HttpServerOptionsCustomizer} SPI bean that
+ * applies them to every listener Quarkus opens.
+ *
+ * The transport bounds fail fast on abusive framing before a request is ever admitted to the
+ * pipeline: an over-long request line, an over-large header block, or an over-large chunk is
+ * rejected by the Vert.x codec, and an idle connection is reaped so a slow-loris / h2 abuse load
+ * cannot pin connection slots. Two further limits are consumed by {@link GatewayEdgeRoute} rather
+ * than the transport: the {@linkplain #admissionCap() admission cap} bounds the number of requests
+ * that may be in flight at once (rejected with {@code 503} before a virtual thread is
+ * dispatched, so a flood cannot spawn unbounded virtual threads), and the
+ * {@linkplain #drainTimeoutMillis() drain timeout} bounds the graceful-shutdown wait for in-flight
+ * requests to complete on {@code SIGTERM}.
+ *
+ * The values are deliberate secure defaults chosen to keep the abuse surface bounded while
+ * comfortably serving ordinary API traffic; the drain timeout is kept below the Quarkus default
+ * shutdown timeout so the drain completes within the shutdown window rather than being cut short.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+@ApplicationScoped
+public class EdgeHardeningOptions implements HttpServerOptionsCustomizer {
+
+ /** Maximum size of the whole request header block in bytes (default 16 KiB). */
+ private static final int MAX_HEADER_SIZE_BYTES = 16 * 1024;
+
+ /** Maximum length of the HTTP/1.x request line ({@code METHOD SP request-target SP HTTP/x}). */
+ private static final int MAX_INITIAL_LINE_LENGTH_BYTES = 8 * 1024;
+
+ /** Maximum size of a single chunked-transfer chunk in bytes. */
+ private static final int MAX_CHUNK_SIZE_BYTES = 16 * 1024;
+
+ /** Idle-connection reap threshold in seconds — an idle connection is closed after this. */
+ private static final int IDLE_TIMEOUT_SECONDS = 60;
+
+ /** Maximum number of requests permitted in flight at once before a virtual thread is dispatched. */
+ private static final int ADMISSION_CAP = 2048;
+
+ /** Bounded graceful-drain wait for in-flight requests on shutdown (below the Quarkus default). */
+ private static final long DRAIN_TIMEOUT_MILLIS = 25_000L;
+
+ @Override
+ public void customizeHttpServer(HttpServerOptions options) {
+ apply(options);
+ }
+
+ @Override
+ public void customizeHttpsServer(HttpServerOptions options) {
+ apply(options);
+ }
+
+ private static void apply(HttpServerOptions options) {
+ options.setMaxHeaderSize(MAX_HEADER_SIZE_BYTES)
+ .setMaxInitialLineLength(MAX_INITIAL_LINE_LENGTH_BYTES)
+ .setMaxChunkSize(MAX_CHUNK_SIZE_BYTES)
+ .setIdleTimeout(IDLE_TIMEOUT_SECONDS)
+ .setIdleTimeoutUnit(TimeUnit.SECONDS);
+ }
+
+ /**
+ * @return the maximum number of concurrently in-flight requests the edge admits before a
+ * virtual thread is dispatched; a request beyond the cap is rejected {@code 503}
+ */
+ public int admissionCap() {
+ return ADMISSION_CAP;
+ }
+
+ /**
+ * @return the bounded graceful-drain wait in milliseconds the edge honours on shutdown
+ */
+ public long drainTimeoutMillis() {
+ return DRAIN_TIMEOUT_MILLIS;
+ }
+}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/edge/GatewayEdgeRoute.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/edge/GatewayEdgeRoute.java
new file mode 100644
index 0000000..53d6a77
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/edge/GatewayEdgeRoute.java
@@ -0,0 +1,615 @@
+/*
+ * 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.edge;
+
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.time.temporal.ChronoUnit;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Semaphore;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+
+
+import de.cuioss.http.forwarded.ForwardedHeaderResolver;
+import de.cuioss.http.forwarded.ForwardedResolverConfig;
+import de.cuioss.http.security.config.SecurityConfiguration;
+import de.cuioss.http.security.config.SecurityConfigurationBuilder;
+import de.cuioss.http.security.monitoring.SecurityEventCounter;
+import de.cuioss.sheriff.api.ApiSheriffLogMessages;
+import de.cuioss.sheriff.api.auth.AuthenticationStage;
+import de.cuioss.sheriff.api.auth.GatewayValidator;
+import de.cuioss.sheriff.api.config.model.ForwardedConfig;
+import de.cuioss.sheriff.api.config.model.GatewayConfig;
+import de.cuioss.sheriff.api.config.model.HttpMethod;
+import de.cuioss.sheriff.api.config.model.RouteTable;
+import de.cuioss.sheriff.api.config.model.SecurityFilterConfig;
+import de.cuioss.sheriff.api.events.EventCategory;
+import de.cuioss.sheriff.api.events.EventType;
+import de.cuioss.sheriff.api.events.GatewayEventCounter;
+import de.cuioss.sheriff.api.events.GatewayException;
+import de.cuioss.sheriff.api.forward.ForwardPolicyStage;
+import de.cuioss.sheriff.api.forward.TcpPeerGate;
+import de.cuioss.sheriff.api.pipeline.BasicChecksStage;
+import de.cuioss.sheriff.api.pipeline.CanonicalPathGuard;
+import de.cuioss.sheriff.api.pipeline.FramingGate;
+import de.cuioss.sheriff.api.pipeline.PipelineRequest;
+import de.cuioss.sheriff.api.pipeline.RouteSelectionStage;
+import de.cuioss.sheriff.api.pipeline.SecurityHeadersStage;
+import de.cuioss.sheriff.api.pipeline.ThoroughChecksStage;
+import de.cuioss.sheriff.api.pipeline.VerbGateStage;
+import de.cuioss.sheriff.api.quarkus.SheriffMetrics;
+import de.cuioss.sheriff.api.routing.ProtocolProcessorRegistry;
+import de.cuioss.sheriff.api.routing.RouteRuntime;
+import de.cuioss.sheriff.token.validation.TokenValidator;
+import de.cuioss.tools.logging.CuiLogger;
+
+import io.quarkus.runtime.ShutdownEvent;
+import io.quarkus.virtual.threads.VirtualThreads;
+import io.smallrye.faulttolerance.api.Guard;
+import io.vertx.core.MultiMap;
+import io.vertx.core.Vertx;
+import io.vertx.core.http.HttpClientResponse;
+import io.vertx.core.http.HttpServerRequest;
+import io.vertx.core.http.HttpServerResponse;
+import io.vertx.ext.web.Router;
+import io.vertx.ext.web.RoutingContext;
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.enterprise.event.Observes;
+import jakarta.enterprise.inject.Instance;
+import jakarta.inject.Inject;
+import org.jspecify.annotations.Nullable;
+
+/**
+ * The public data-plane edge: a single catch-all Vert.x route that runs every inbound request
+ * through the fixed pipeline (stages 0-7) on a virtual thread, then relays the streamed upstream
+ * response back to the client.
+ *
+ * Boot-time assembly. The constructor compiles the frozen {@link RouteTable} into
+ * immutable {@link RouteRuntime} instances via the {@link RouteRuntimeAssembler} (deduplicating the
+ * shared Vert.x clients and SmallRye guards), builds every stage once with the shared
+ * {@link SecurityEventCounter}, the default {@link SecurityConfiguration}, the boot-wired
+ * {@link ForwardedHeaderResolver} + {@link TcpPeerGate} (from the global {@code forwarded} block),
+ * and the shared {@link GatewayEventCounter}. That same shared {@link SecurityEventCounter} is bound
+ * to {@link SheriffMetrics} here so its per-{@code UrlSecurityFailureType} counts surface as the
+ * {@code sheriff_security_events_total} meter. An unsupported protocol or {@code session} auth fails
+ * boot here (fail fast), so no request is ever served on an invalid route set.
+ *
+ * Per-request flow. The catch-all is registered {@linkplain io.vertx.ext.web.Route#last()
+ * last} so management / health routes keep working. Each request is admitted under a bounded
+ * {@linkplain EdgeHardeningOptions#admissionCap() admission cap} before a virtual thread is
+ * dispatched (a flood is rejected {@code 503} rather than spawning unbounded virtual threads), then
+ * the request stream is paused and the whole pipeline runs on a virtual thread:
+ *
+ * - stage 0 — response-header preparation + CORS preflight (short-circuits a preflight here);
+ * - stage 1 — baseline security filter (records the single canonical path), the canonical-path
+ * guard, and the framing gate;
+ * - stage 2 / 2b — deny-by-default route selection then the per-route verb gate;
+ * - stage 3 — per-route thorough checks ({@code allowed_paths}, body cap, divergent pipeline);
+ * - stage 4 — offline bearer-token validation;
+ * - stage 5 — the zero-trust forward policy, consuming the route's resolved
+ * {@link RouteRuntime#getEffectiveForward() effectiveForward} and the global forwarded block;
+ * - stage 6 / 7 — streamed upstream dispatch (byte-capped) and the streamed response relay.
+ *
+ * A {@link GatewayException} at any stage is rendered as an RFC 9457 {@code application/problem+json}
+ * response carrying the failing event's status and problem type, never leaking internal detail. On
+ * {@code SIGTERM} the edge stops admitting new requests and drains in-flight ones within a bounded
+ * window.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+@ApplicationScoped
+public class GatewayEdgeRoute {
+
+ private static final CuiLogger LOGGER = new CuiLogger(GatewayEdgeRoute.class);
+
+ private static final String PROBLEM_JSON = "application/problem+json";
+ private static final String DEFAULT_EMIT_MODE = "x-forwarded";
+ private static final int SERVICE_UNAVAILABLE = 503;
+ private static final int INTERNAL_ERROR = 500;
+ private static final int BAD_GATEWAY = 502;
+ private static final long DRAIN_POLL_INTERVAL_MILLIS = 50L;
+
+ /** Per-request {@link RoutingContext} data key holding the resolved metrics route label. */
+ private static final String ROUTE_KEY = "sheriff.route";
+
+ private final List routes;
+ private final ExecutorService virtualThreadExecutor;
+ private final EdgeHardeningOptions hardening;
+
+ private final long defaultMaxBodySize;
+ private final GatewayEventCounter gatewayEventCounter;
+ private final UpstreamFailureMapper upstreamFailureMapper;
+ private final SheriffMetrics sheriffMetrics;
+
+ private final SecurityHeadersStage securityHeadersStage;
+ private final BasicChecksStage basicChecksStage;
+ private final CanonicalPathGuard canonicalPathGuard;
+ private final FramingGate framingGate;
+ private final RouteSelectionStage routeSelectionStage;
+ private final VerbGateStage verbGateStage;
+ private final ThoroughChecksStage thoroughChecksStage;
+ private final AuthenticationStage authenticationStage;
+ private final ForwardPolicyStage forwardPolicyStage;
+ private final ResponseStage responseStage;
+
+ private final Semaphore admission;
+ private final AtomicInteger inFlight = new AtomicInteger();
+ private volatile boolean draining;
+
+ /**
+ * Assembles the routes and every stage once, at boot.
+ *
+ * @param routeTable the frozen, longest-prefix-ordered route table
+ * @param gatewayConfig the bound global gateway document (source of the {@code forwarded}
+ * block and {@code security_headers})
+ * @param tokenValidator a lazy CDI handle to the gateway's shared offline bearer-token
+ * validator; resolved via {@link Instance#get()} only when a
+ * {@code require: bearer} route actually validates a token, so a
+ * config with only {@code require: none} routes never touches the
+ * validator producer (and never fails boot on a missing
+ * {@code token_validation} block)
+ * @param vertx the Vert.x instance the per-tuple upstream clients are created on
+ * @param virtualThreadExecutor the Quarkus-managed virtual-thread executor
+ * @param hardening the edge transport / admission bounds
+ * @param sheriffMetrics the Micrometer adapter the request/error/upstream signals are
+ * recorded through
+ */
+ @Inject
+ public GatewayEdgeRoute(RouteTable routeTable, GatewayConfig gatewayConfig,
+ @GatewayValidator Instance tokenValidator, Vertx vertx,
+ @VirtualThreads ExecutorService virtualThreadExecutor, EdgeHardeningOptions hardening,
+ SheriffMetrics sheriffMetrics) {
+ this.virtualThreadExecutor = virtualThreadExecutor;
+ this.hardening = hardening;
+ this.sheriffMetrics = sheriffMetrics;
+ this.admission = new Semaphore(hardening.admissionCap());
+
+ SecurityEventCounter securityEventCounter = new SecurityEventCounter();
+ SecurityConfiguration defaultConfiguration = SecurityConfiguration.defaults();
+ this.defaultMaxBodySize = defaultConfiguration.maxBodySize();
+ this.gatewayEventCounter = new GatewayEventCounter();
+ this.upstreamFailureMapper = new UpstreamFailureMapper(gatewayEventCounter);
+
+ List trustedProxies = gatewayConfig.forwarded()
+ .map(ForwardedConfig::trustedProxies).orElse(List.of());
+ String emitMode = gatewayConfig.forwarded().flatMap(ForwardedConfig::emit).orElse(DEFAULT_EMIT_MODE);
+ ForwardedResolverConfig resolverConfig = ForwardedResolverConfig.builder()
+ .trustedProxies(Set.copyOf(trustedProxies))
+ .securityConfig(defaultConfiguration)
+ .build();
+ ForwardedHeaderResolver resolver = new ForwardedHeaderResolver(resolverConfig, securityEventCounter);
+ TcpPeerGate peerGate = new TcpPeerGate(trustedProxies);
+
+ RouteRuntimeAssembler assembler = new RouteRuntimeAssembler(new ProtocolProcessorRegistry());
+ this.routes = assembler.assemble(routeTable,
+ GatewayEdgeRoute::securityConfigurationFor,
+ target -> vertx.createHttpClient(),
+ this::guardFor);
+ LOGGER.info(ApiSheriffLogMessages.INFO.ROUTE_TABLE_COMPILED, routes.size());
+
+ this.securityHeadersStage = new SecurityHeadersStage(gatewayConfig.securityHeaders());
+ this.basicChecksStage = new BasicChecksStage(defaultConfiguration, securityEventCounter);
+ this.canonicalPathGuard = new CanonicalPathGuard();
+ this.framingGate = new FramingGate();
+ this.routeSelectionStage = new RouteSelectionStage(routes);
+ this.verbGateStage = new VerbGateStage();
+ this.thoroughChecksStage = new ThoroughChecksStage(defaultConfiguration, securityEventCounter);
+ this.authenticationStage = new AuthenticationStage(tokenValidator);
+ this.forwardPolicyStage = new ForwardPolicyStage(resolver, peerGate, emitMode);
+ this.responseStage = new ResponseStage();
+
+ // Bind the boot-shared cui-http counter to Micrometer so the per-UrlSecurityFailureType
+ // security-filter counts surface as sheriff_security_events_total, completing the fixed
+ // five-meter contract alongside the request/duration/error/upstream meters recorded above.
+ sheriffMetrics.bindSecurityEventCounter(securityEventCounter);
+ }
+
+ /**
+ * Registers the catch-all data-plane route, last, so management / health routes keep priority.
+ *
+ * @param router the Vert.x web router, observed during Quarkus startup
+ */
+ public void registerRoutes(@Observes Router router) {
+ router.route().last().handler(this::handle);
+ LOGGER.debug("Registered catch-all gateway edge over %s route(s)", routes.size());
+ }
+
+ /**
+ * Stops admitting new requests on {@code SIGTERM} and drains in-flight ones within a bounded
+ * window so the shutdown completes cleanly within the Quarkus shutdown timeout.
+ *
+ * @param event the Quarkus shutdown event
+ */
+ void onShutdown(@Observes ShutdownEvent event) {
+ draining = true;
+ long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(hardening.drainTimeoutMillis());
+ while (inFlight.get() > 0 && System.nanoTime() < deadline) {
+ try {
+ Thread.sleep(DRAIN_POLL_INTERVAL_MILLIS);
+ } catch (InterruptedException interrupted) {
+ Thread.currentThread().interrupt();
+ return;
+ }
+ }
+ LOGGER.debug("Edge drain finished; in-flight=%s", inFlight.get());
+ }
+
+ private void handle(RoutingContext ctx) {
+ if (draining || !admission.tryAcquire()) {
+ reject(ctx, SERVICE_UNAVAILABLE);
+ return;
+ }
+ long startNanos = System.nanoTime();
+ inFlight.incrementAndGet();
+ // Guard the admission accounting so it is rolled back exactly once. The end handler releases
+ // it on the normal path; the executor-rejection path below also rolls it back, and both must
+ // never double-release (reject() ends the response, which itself fires this end handler).
+ AtomicBoolean admissionReleased = new AtomicBoolean();
+ ctx.addEndHandler(result -> {
+ releaseAdmission(admissionReleased);
+ recordRequestMetrics(ctx, startNanos);
+ });
+ ctx.request().pause();
+ try {
+ virtualThreadExecutor.execute(() -> process(ctx));
+ } /*~~(TODO: Catch specific not RuntimeException. Suppress: // cui-rewrite:disable InvalidExceptionUsageRecipe)~~>*/ catch (RuntimeException rejected) {
+ // The virtual-thread executor refused the dispatch (e.g. RejectedExecutionException during
+ // a shutdown race), so process(ctx) will never run and the response would otherwise never
+ // end — leaking the admission permit and the in-flight count. Roll the admission back now
+ // (idempotently) and fail the request 503 directly.
+ LOGGER.debug(rejected, "Virtual-thread dispatch rejected: %s", rejected.getMessage());
+ releaseAdmission(admissionReleased);
+ reject(ctx, SERVICE_UNAVAILABLE);
+ }
+ }
+
+ /**
+ * Releases one admission permit and decrements the in-flight counter exactly once, guarded by
+ * {@code released} so the normal end-handler path and the executor-rejection rollback path can
+ * both call it without double-counting.
+ */
+ private void releaseAdmission(AtomicBoolean released) {
+ if (released.compareAndSet(false, true)) {
+ admission.release();
+ inFlight.decrementAndGet();
+ }
+ }
+
+ /**
+ * Records the terminal {@link SheriffMetrics#REQUESTS_TOTAL request count} and
+ * {@link SheriffMetrics#REQUEST_DURATION_SECONDS request-duration timer} for one served request
+ * from the single end-of-response hook, so every terminal path (streamed success, short-circuit,
+ * and rendered failure) is metered exactly once. The bounded {@code route} label is the id
+ * stashed at route selection, or {@link SheriffMetrics#NO_ROUTE} for an unmatched or
+ * short-circuited request.
+ */
+ private void recordRequestMetrics(RoutingContext ctx, long startNanos) {
+ Duration elapsed = Duration.ofNanos(System.nanoTime() - startNanos);
+ String route = routeLabel(ctx);
+ String method = ctx.request().method().name();
+ sheriffMetrics.recordRequest(route, method, SheriffMetrics.statusFamily(ctx.response().getStatusCode()));
+ sheriffMetrics.recordRequestDuration(route, elapsed);
+ }
+
+ /**
+ * Counts one categorized failure against {@link SheriffMetrics#ERRORS_TOTAL}, keyed by the
+ * request's stashed route (or {@link SheriffMetrics#NO_ROUTE}) and the failure
+ * {@link EventCategory}. An uncategorized failure (e.g. an unexpected internal error) carries no
+ * category and surfaces only through the {@code 5xx} bucket of {@link SheriffMetrics#REQUESTS_TOTAL}.
+ */
+ private void recordError(RoutingContext ctx, EventType eventType) {
+ EventCategory category = eventType.category();
+ if (category != null) {
+ sheriffMetrics.recordError(routeLabel(ctx), category);
+ }
+ }
+
+ private static String routeLabel(RoutingContext ctx) {
+ String route = ctx.get(ROUTE_KEY);
+ return route != null ? route : SheriffMetrics.NO_ROUTE;
+ }
+
+ private void process(RoutingContext ctx) {
+ PipelineRequest request = null;
+ try {
+ HttpServerRequest raw = ctx.request();
+ Optional parsedMethod = parseMethod(raw.method().name());
+ if (parsedMethod.isEmpty()) {
+ renderProblem(ctx, null, EventType.METHOD_NOT_ALLOWED);
+ return;
+ }
+ HttpMethod method = parsedMethod.get();
+ request = buildPipelineRequest(raw, method);
+
+ securityHeadersStage.process(request);
+ basicChecksStage.process(request);
+ canonicalPathGuard.process(request);
+ framingGate.process(request);
+ if (request.shortCircuitStatus().isPresent()) {
+ writeShortCircuit(ctx, request);
+ return;
+ }
+ routeSelectionStage.process(request);
+ verbGateStage.process(request);
+ RouteRuntime route = requireSelectedRoute(request);
+ ctx.put(ROUTE_KEY, route.getId());
+ thoroughChecksStage.process(request, route.getEffectiveAllowedPaths());
+ authenticationStage.process(request);
+ ForwardPolicyStage.Result forward = forwardPolicyStage.process(request,
+ route.getEffectiveForward(), route.isNotModifiedEnabled());
+ dispatchAndRelay(ctx, request, route, forward);
+ } catch (GatewayException rejected) {
+ // Upstream failures are already metered inside UpstreamFailureMapper; meter the rest here.
+ if (rejected.getEventType().category() != EventCategory.UPSTREAM) {
+ gatewayEventCounter.increment(rejected.getEventType());
+ }
+ if (rejected.getEventType() == EventType.SECURITY_FILTER_VIOLATION) {
+ // Security-relevant WARN (D4): the failure-type detail only, never the raw payload —
+ // rejected.getMessage() already carries a sanitized description (see GatewayException).
+ LOGGER.warn(ApiSheriffLogMessages.WARN.SECURITY_FILTER_VIOLATION, routeLabel(ctx), rejected.getMessage());
+ }
+ recordError(ctx, rejected.getEventType());
+ renderProblem(ctx, request, rejected.getEventType());
+ } /*~~(TODO: Catch specific not RuntimeException. Suppress: // cui-rewrite:disable InvalidExceptionUsageRecipe)~~>*/ catch (RuntimeException unexpected) {
+ LOGGER.debug(unexpected, "Unexpected edge failure: %s", unexpected.getMessage());
+ renderProblem(ctx, request, null);
+ }
+ }
+
+ private void dispatchAndRelay(RoutingContext ctx, PipelineRequest request, RouteRuntime route,
+ ForwardPolicyStage.Result forward) {
+ String prefix = stripTrailingSlash(route.getMatcher().pathPrefix());
+ String canonical = requireCanonicalPath(request);
+ String remainder = canonical.length() >= prefix.length() ? canonical.substring(prefix.length()) : "";
+ String query = renderQuery(forward.query());
+ String uri = DispatchStage.upstreamRequestUri(route.getUpstream(), remainder, query);
+ long cap = route.getSecurityConfiguration().map(SecurityConfiguration::maxBodySize).orElse(defaultMaxBodySize);
+
+ DispatchStage dispatchStage = new DispatchStage(cap, upstreamFailureMapper);
+ long upstreamStartNanos = System.nanoTime();
+ HttpClientResponse upstream = dispatchStage.dispatch(route, request.method(), uri, forward.headers(),
+ ctx.request());
+ sheriffMetrics.recordUpstreamDuration(route.getId(), Duration.ofNanos(System.nanoTime() - upstreamStartNanos));
+ gatewayEventCounter.increment(EventType.REQUEST_FORWARDED);
+ // The relay mutates the Vert.x HttpServerResponse (status, headers) and subscribes the
+ // upstream pipeTo — all of which are event-loop-bound and NOT safe to touch from this
+ // virtual thread. Hop back onto the event loop exactly like every other terminal path
+ // (renderProblem / writeShortCircuit / failRelay); doing the relay off-loop races the
+ // response object and corrupts / truncates the streamed body.
+ ctx.vertx().runOnContext(v ->
+ responseStage.relay(upstream, ctx.response(), route.isNotModifiedEnabled(), request.responseHeaders())
+ .onFailure(failure -> failRelay(ctx, failure)));
+ }
+
+ private void failRelay(RoutingContext ctx, Throwable failure) {
+ LOGGER.debug(failure, "Response relay failed: %s", failure.getMessage());
+ ctx.vertx().runOnContext(v -> {
+ HttpServerResponse response = ctx.response();
+ if (response.ended()) {
+ return;
+ }
+ // If the relay failed mid-stream after the response head was already written, the status
+ // line is gone — Vert.x throws IllegalStateException on setStatusCode once headWritten()
+ // is true. Only set the 502 when the head has not yet been written; either way end() the
+ // (possibly truncated) response so the client connection is closed cleanly.
+ if (!response.headWritten()) {
+ response.setStatusCode(BAD_GATEWAY);
+ }
+ response.end();
+ });
+ }
+
+ private void writeShortCircuit(RoutingContext ctx, PipelineRequest request) {
+ int status = request.shortCircuitStatus().orElse(204);
+ Map responseHeaders = Map.copyOf(request.responseHeaders());
+ ctx.vertx().runOnContext(v -> {
+ HttpServerResponse response = ctx.response();
+ if (response.ended()) {
+ return;
+ }
+ response.setStatusCode(status);
+ responseHeaders.forEach(response::putHeader);
+ response.end();
+ });
+ }
+
+ private void renderProblem(RoutingContext ctx, @Nullable PipelineRequest request, @Nullable EventType eventType) {
+ int status;
+ String type;
+ String title;
+ if (eventType == null) {
+ status = INTERNAL_ERROR;
+ type = "about:blank";
+ title = "Internal Server Error";
+ } else {
+ status = eventType.hasHttpMapping() ? eventType.httpStatus() : INTERNAL_ERROR;
+ EventCategory category = eventType.category();
+ type = category != null ? category.problemType() : "about:blank";
+ title = category != null ? category.title() : "Internal Server Error";
+ }
+ String body = "{\"type\":\"" + type + "\",\"title\":\"" + title + "\",\"status\":" + status + "}";
+ Map responseHeaders = request != null ? Map.copyOf(request.responseHeaders()) : Map.of();
+ ctx.vertx().runOnContext(v -> {
+ HttpServerResponse response = ctx.response();
+ if (response.ended()) {
+ return;
+ }
+ response.setStatusCode(status);
+ responseHeaders.forEach(response::putHeader);
+ response.putHeader("Content-Type", PROBLEM_JSON);
+ response.end(body);
+ });
+ }
+
+ private static void reject(RoutingContext ctx, int status) {
+ HttpServerResponse response = ctx.response();
+ if (!response.ended()) {
+ response.setStatusCode(status).end();
+ }
+ }
+
+ private static PipelineRequest buildPipelineRequest(HttpServerRequest raw, HttpMethod method) {
+ String rawUri = raw.uri();
+ int queryStart = rawUri.indexOf('?');
+ String rawPath = queryStart < 0 ? rawUri : rawUri.substring(0, queryStart);
+ long contentLength = parseContentLength(raw);
+ boolean bodyPresent = contentLength > 0 || raw.headers().contains("Transfer-Encoding");
+ return PipelineRequest.builder()
+ .method(method)
+ .requestPath(rawPath)
+ .queryParameters(toListMap(raw.params()))
+ .headers(toListMap(raw.headers()))
+ .host(raw.authority() != null ? raw.authority().host() : raw.host())
+ .peerAddress(raw.remoteAddress() != null ? raw.remoteAddress().hostAddress() : null)
+ .declaredContentLength(contentLength)
+ .bodyPresent(bodyPresent)
+ .build();
+ }
+
+ private static Map> toListMap(MultiMap multiMap) {
+ Map> map = new LinkedHashMap<>();
+ for (String name : multiMap.names()) {
+ map.put(name, List.copyOf(multiMap.getAll(name)));
+ }
+ return map;
+ }
+
+ private static long parseContentLength(HttpServerRequest raw) {
+ String value = raw.getHeader("Content-Length");
+ if (value == null) {
+ return -1L;
+ }
+ try {
+ return Long.parseLong(value.strip());
+ } catch (NumberFormatException notANumber) {
+ return -1L;
+ }
+ }
+
+ private static String renderQuery(Map> query) {
+ if (query.isEmpty()) {
+ return "";
+ }
+ StringBuilder rendered = new StringBuilder("?");
+ boolean first = true;
+ for (Map.Entry> entry : query.entrySet()) {
+ for (String value : entry.getValue()) {
+ if (!first) {
+ rendered.append('&');
+ }
+ rendered.append(encode(entry.getKey())).append('=').append(encode(value));
+ first = false;
+ }
+ }
+ return rendered.toString();
+ }
+
+ private static String encode(String value) {
+ return URLEncoder.encode(value, StandardCharsets.UTF_8);
+ }
+
+ private static Optional parseMethod(String name) {
+ try {
+ return Optional.of(HttpMethod.valueOf(name));
+ } catch (IllegalArgumentException unsupported) {
+ return Optional.empty();
+ }
+ }
+
+ private static RouteRuntime requireSelectedRoute(PipelineRequest request) {
+ RouteRuntime route = request.selectedRoute();
+ if (route == null) {
+ throw new IllegalStateException("Route dispatch requires the route selected at stage 2");
+ }
+ return route;
+ }
+
+ private static String requireCanonicalPath(PipelineRequest request) {
+ String canonical = request.canonicalPath();
+ if (canonical == null) {
+ throw new IllegalStateException("Route dispatch requires the canonical path resolved at stage 1");
+ }
+ return canonical;
+ }
+
+ private static String stripTrailingSlash(String value) {
+ return value.endsWith("/") ? value.substring(0, value.length() - 1) : value;
+ }
+
+ /**
+ * Maps a route's {@code security_filter} block to a cui-http {@link SecurityConfiguration},
+ * seeding the safe builder defaults and overriding only the limits the route declared, so an
+ * undeclared dimension never falls below the gateway baseline.
+ */
+ private static SecurityConfiguration securityConfigurationFor(SecurityFilterConfig filter) {
+ SecurityConfigurationBuilder builder = SecurityConfiguration.builder();
+ filter.maxBodyBytes().ifPresent(value -> builder.maxBodySize(value.longValue()));
+ filter.maxQueryParams().ifPresent(builder::maxParameterCount);
+ filter.maxHeaderCount().ifPresent(builder::maxHeaderCount);
+ filter.maxParamValueLength().ifPresent(builder::maxParameterValueLength);
+ filter.maxHeaderValueLength().ifPresent(builder::maxHeaderValueLength);
+ if (!filter.allowedHeaderNames().isEmpty()) {
+ builder.allowedHeaderNames(Set.copyOf(filter.allowedHeaderNames()));
+ }
+ if (!filter.blockedHeaderNames().isEmpty()) {
+ builder.blockedHeaderNames(Set.copyOf(filter.blockedHeaderNames()));
+ }
+ if (!filter.allowedContentTypes().isEmpty()) {
+ builder.allowedContentTypes(Set.copyOf(filter.allowedContentTypes()));
+ }
+ return builder.build();
+ }
+
+ /**
+ * Builds the per-shape SmallRye Fault-Tolerance guard: a circuit breaker plus an upstream
+ * timeout, with retry added only for a route that enables it. Gateway rejections
+ * ({@link GatewayException}, e.g. a body-cap breach) are skipped so they never trip the breaker
+ * or trigger a retry.
+ */
+ private Guard guardFor(RouteRuntimeAssembler.ResilienceShape shape) {
+ // Include retryEnabled in the breaker name: RouteRuntimeAssembler's guardCache is keyed by the
+ // full ResilienceShape (target + retryEnabled), so two routes to the same host:port that differ
+ // only in retryEnabled resolve to two distinct guards. Deriving the name from host:port alone
+ // would hand both the SAME programmatic circuit-breaker name, which SmallRye rejects as a
+ // duplicate. The retry-qualified name is used for both .name(...) and the transition callback.
+ String name = shape.target().host() + ":" + shape.target().port() + ":retry=" + shape.retryEnabled();
+ Guard.Builder builder = Guard.create().withDescription("upstream-" + name);
+ builder.withCircuitBreaker()
+ .name(name)
+ .requestVolumeThreshold(20)
+ .failureRatio(0.5)
+ .delay(5, ChronoUnit.SECONDS)
+ .successThreshold(2)
+ .skipOn(GatewayException.class)
+ .onStateChange(state -> upstreamFailureMapper.recordBreakerTransition(name, state))
+ .done();
+ builder.withTimeout().duration(30, ChronoUnit.SECONDS).done();
+ if (shape.retryEnabled()) {
+ builder.withRetry().maxRetries(2).delay(100, ChronoUnit.MILLIS)
+ .abortOn(GatewayException.class).done();
+ }
+ return builder.build();
+ }
+}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/edge/ResponseStage.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/edge/ResponseStage.java
new file mode 100644
index 0000000..086bc63
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/edge/ResponseStage.java
@@ -0,0 +1,125 @@
+/*
+ * 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.edge;
+
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+
+import io.vertx.core.Future;
+import io.vertx.core.http.HttpClientResponse;
+import io.vertx.core.http.HttpServerResponse;
+
+/**
+ * Stage 7 — the streamed response relay.
+ *
+ * The upstream status and (hop-by-hop-stripped) headers are copied to the client response, then the
+ * upstream body is streamed back with backpressure via Vert.x
+ * {@link HttpClientResponse#pipeTo(io.vertx.core.streams.WriteStream) pipeTo} — the body is never
+ * materialized. Header rules:
+ *
+ * - Hop-by-hop headers (RFC 7230 §6.1) plus length/framing headers Vert.x
+ * recomputes are stripped in the response direction.
+ * - Conditional-response headers ({@code ETag} / {@code Last-Modified}) pass
+ * through only when the route enables {@code not_modified}; on a disabled route they are
+ * stripped so no validator ever reaches the client. A {@code 304} on an enabled route relays
+ * untouched.
+ * - Stage-0 security headers accumulated on the response are applied last, so
+ * gateway-controlled headers win over any upstream value.
+ *
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+public final class ResponseStage {
+
+ /** Hop-by-hop (RFC 7230 §6.1) plus framing headers Vert.x recomputes. All lower case. */
+ private static final Set HOP_BY_HOP_HEADERS = Set.of(
+ "connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
+ "te", "trailer", "transfer-encoding", "upgrade", "content-length");
+
+ /** Conditional-response validators stripped unless the route enables {@code not_modified}. */
+ private static final Set CONDITIONAL_RESPONSE_HEADERS = Set.of("etag", "last-modified");
+
+ /**
+ * @param name the upstream response-header name
+ * @param notModifiedEnabled whether the route honours conditional requests / responses
+ * @return {@code true} when the header crosses back to the client
+ */
+ public static boolean isForwardableResponseHeader(String name, boolean notModifiedEnabled) {
+ String lower = Objects.requireNonNull(name, "name").toLowerCase(Locale.ROOT);
+ if (HOP_BY_HOP_HEADERS.contains(lower)) {
+ return false;
+ }
+ return notModifiedEnabled || !CONDITIONAL_RESPONSE_HEADERS.contains(lower);
+ }
+
+ /**
+ * Relays the upstream response to the client: status, filtered headers, stage-0 security
+ * headers, then the streamed body.
+ *
+ * @param upstream the upstream response (body still streaming)
+ * @param client the client response write stream
+ * @param notModifiedEnabled whether the route honours conditional requests / responses
+ * @param stageZeroSecurityHeaders the stage-0 security headers accumulated on the response
+ * @return a future completing when the body has been fully streamed
+ */
+ public Future relay(HttpClientResponse upstream, HttpServerResponse client,
+ boolean notModifiedEnabled, Map stageZeroSecurityHeaders) {
+ Objects.requireNonNull(upstream, "upstream");
+ Objects.requireNonNull(client, "client");
+ Objects.requireNonNull(stageZeroSecurityHeaders, "stageZeroSecurityHeaders");
+
+ client.setStatusCode(upstream.statusCode());
+ for (Map.Entry header : upstream.headers()) {
+ if (isForwardableResponseHeader(header.getKey(), notModifiedEnabled)) {
+ // add (not set) so multi-valued headers such as Set-Cookie are all preserved.
+ client.headers().add(header.getKey(), header.getValue());
+ }
+ }
+ stageZeroSecurityHeaders.forEach((name, value) -> client.headers().set(name, value));
+ applyResponseFraming(upstream, client);
+ return upstream.pipeTo(client);
+ }
+
+ /**
+ * Re-establishes the client response body framing after {@link #isForwardableResponseHeader
+ * hop-by-hop stripping} removed the upstream framing headers. Only {@code Transfer-Encoding} is
+ * hop-by-hop and must be recomputed by the client protocol; {@code Content-Length} is end-to-end
+ * and — because the body is relayed byte-for-byte — remains accurate. Preserve a declared
+ * upstream {@code Content-Length} so an HTTP/1.1 client receives a well-framed fixed-length body;
+ * when the upstream framed the body as chunked (no {@code Content-Length}), stream the client
+ * response chunked. Without this an HTTP/1.1 response defaults to {@code Content-Length: 0} and
+ * the streamed body is silently dropped (HTTP/2 ignores both signals and is unaffected).
+ */
+ private static void applyResponseFraming(HttpClientResponse upstream, HttpServerResponse client) {
+ String contentLength = upstream.getHeader("Content-Length");
+ if (contentLength != null) {
+ client.putHeader("Content-Length", contentLength);
+ } else if (mayCarryBody(upstream.statusCode())) {
+ client.setChunked(true);
+ }
+ }
+
+ /**
+ * @param status the relayed response status
+ * @return {@code true} unless the status forbids a message body (1xx, 204, 304)
+ */
+ static boolean mayCarryBody(int status) {
+ return status != 204 && status != 304 && (status < 100 || status >= 200);
+ }
+}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/edge/RouteRuntimeAssembler.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/edge/RouteRuntimeAssembler.java
new file mode 100644
index 0000000..22de50f
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/edge/RouteRuntimeAssembler.java
@@ -0,0 +1,214 @@
+/*
+ * 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.edge;
+
+import java.util.ArrayList;
+import java.util.EnumSet;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+
+
+import de.cuioss.http.security.config.SecurityConfiguration;
+import de.cuioss.sheriff.api.config.model.HttpMethod;
+import de.cuioss.sheriff.api.config.model.ResolvedRoute;
+import de.cuioss.sheriff.api.config.model.ResolvedUpstream;
+import de.cuioss.sheriff.api.config.model.RouteTable;
+import de.cuioss.sheriff.api.config.model.SecurityFilterConfig;
+import de.cuioss.sheriff.api.events.EventType;
+import de.cuioss.sheriff.api.events.GatewayException;
+import de.cuioss.sheriff.api.routing.ProtocolProcessor;
+import de.cuioss.sheriff.api.routing.ProtocolProcessorRegistry;
+import de.cuioss.sheriff.api.routing.RouteMatcher;
+import de.cuioss.sheriff.api.routing.RouteRuntime;
+
+import io.smallrye.faulttolerance.api.Guard;
+import io.vertx.core.http.HttpClient;
+
+/**
+ * Boot-time assembler compiling the frozen {@link RouteTable} into immutable
+ * {@link RouteRuntime} instances, deduplicating the heavy collaborators so shared shapes
+ * reuse one object rather than copying it:
+ *
+ * - one cui-http {@link SecurityConfiguration} per distinct {@link SecurityFilterConfig} shape;
+ * - one Vert.x {@link HttpClient} per distinct {@linkplain UpstreamTarget upstream-target tuple}
+ * (scheme, host, port) — routes sharing a tuple hold the same client reference;
+ * - one SmallRye Fault-Tolerance {@link Guard} per distinct {@linkplain ResilienceShape
+ * resilience shape}.
+ *
+ * The heavy objects are produced by the injected factories (so tests supply fakes and the
+ * production wiring supplies the real Vert.x / SmallRye instances). A route requesting
+ * {@code session} auth, or a {@code GRPC} / {@code WEBSOCKET} protocol, fails boot with a
+ * {@link GatewayException} carrying {@link EventType#CONFIG_INVALID}.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+public final class RouteRuntimeAssembler {
+
+ private static final String SESSION_REQUIRE = "session";
+
+ private final ProtocolProcessorRegistry protocolRegistry;
+
+ /**
+ * @param protocolRegistry the registry selecting a processor per route and rejecting
+ * unsupported protocols at boot
+ */
+ public RouteRuntimeAssembler(ProtocolProcessorRegistry protocolRegistry) {
+ this.protocolRegistry = Objects.requireNonNull(protocolRegistry, "protocolRegistry");
+ }
+
+ /**
+ * Compiles every route in {@code table} into a {@link RouteRuntime}, sharing the deduplicated
+ * heavy collaborators.
+ *
+ * @param table the frozen route table
+ * @param securityConfigFactory builds one {@link SecurityConfiguration} per security-filter shape
+ * @param clientFactory builds one {@link HttpClient} per upstream target tuple
+ * @param guardFactory builds one {@link Guard} per resilience shape
+ * @return the assembled runtimes, in the table's longest-prefix-first order
+ * @throws GatewayException when a route requests {@code session} auth or an unsupported protocol
+ */
+ public List assemble(RouteTable table, SecurityConfigurationFactory securityConfigFactory,
+ UpstreamClientFactory clientFactory, ResilienceGuardFactory guardFactory) {
+ Objects.requireNonNull(table, "table");
+ Objects.requireNonNull(securityConfigFactory, "securityConfigFactory");
+ Objects.requireNonNull(clientFactory, "clientFactory");
+ Objects.requireNonNull(guardFactory, "guardFactory");
+
+ Map securityCache = new HashMap<>();
+ Map clientCache = new HashMap<>();
+ Map guardCache = new HashMap<>();
+ List runtimes = new ArrayList<>();
+
+ for (ResolvedRoute route : table.routes()) {
+ rejectUnsupportedAuth(route);
+ ProtocolProcessor processor = protocolRegistry.require(route.protocol(), route.id());
+
+ Optional securityConfiguration = route.effectiveSecurityFilter()
+ .map(filter -> securityCache.computeIfAbsent(filter, securityConfigFactory::create));
+
+ UpstreamTarget target = UpstreamTarget.of(route.upstream());
+ HttpClient client = clientCache.computeIfAbsent(target, clientFactory::create);
+
+ ResilienceShape shape = new ResilienceShape(target, route.retryEnabled());
+ Guard guard = guardCache.computeIfAbsent(shape, guardFactory::create);
+
+ runtimes.add(RouteRuntime.builder()
+ .id(route.id())
+ .protocol(route.protocol())
+ .matcher(RouteMatcher.from(route.match()))
+ .protocolProcessor(processor)
+ .effectiveAllowedMethods(toMethodSet(route.effectiveAllowedMethods()))
+ .effectiveAuth(route.effectiveAuth())
+ .requiredScopes(route.effectiveAuth().requiredScopes())
+ .securityConfiguration(securityConfiguration)
+ .securityHeaders(route.effectiveSecurityHeaders())
+ .effectiveForward(route.effectiveForward())
+ .effectiveAllowedPaths(route.effectiveSecurityFilter()
+ .map(SecurityFilterConfig::allowedPaths).orElse(List.of()))
+ .retryEnabled(route.retryEnabled())
+ .notModifiedEnabled(route.notModifiedEnabled())
+ .upstream(route.upstream())
+ .httpClient(client)
+ .resilienceGuard(guard)
+ .build());
+ }
+ return List.copyOf(runtimes);
+ }
+
+ private static void rejectUnsupportedAuth(ResolvedRoute route) {
+ if (SESSION_REQUIRE.equals(route.effectiveAuth().require())) {
+ throw new GatewayException(EventType.CONFIG_INVALID,
+ "Route '" + route.id() + "' requires session authentication which is not yet implemented");
+ }
+ }
+
+ private static Set toMethodSet(List methods) {
+ return methods.isEmpty() ? EnumSet.noneOf(HttpMethod.class) : EnumSet.copyOf(methods);
+ }
+
+ /**
+ * The upstream-target tuple keying Vert.x client dedup: routes sharing (scheme, host, port)
+ * share one client instance.
+ *
+ * @param scheme the upstream scheme
+ * @param host the upstream host
+ * @param port the upstream port
+ */
+ public record UpstreamTarget(String scheme, String host, int port) {
+
+ /**
+ * @param upstream the resolved upstream
+ * @return the target tuple for {@code upstream}
+ */
+ public static UpstreamTarget of(ResolvedUpstream upstream) {
+ return new UpstreamTarget(upstream.scheme(), upstream.host(), upstream.port());
+ }
+ }
+
+ /**
+ * The resilience shape keying Fault-Tolerance guard dedup: routes sharing an upstream target
+ * and retry posture share one guard instance.
+ *
+ * @param target the upstream target
+ * @param retryEnabled the materialized retry toggle
+ */
+ public record ResilienceShape(UpstreamTarget target, boolean retryEnabled) {
+ }
+
+ /**
+ * Factory building one cui-http {@link SecurityConfiguration} for a security-filter shape.
+ */
+ @FunctionalInterface
+ public interface SecurityConfigurationFactory {
+
+ /**
+ * @param filter the security-filter shape
+ * @return the built security configuration
+ */
+ SecurityConfiguration create(SecurityFilterConfig filter);
+ }
+
+ /**
+ * Factory building one Vert.x {@link HttpClient} for an upstream target tuple.
+ */
+ @FunctionalInterface
+ public interface UpstreamClientFactory {
+
+ /**
+ * @param target the upstream target tuple
+ * @return the built (or shared) client
+ */
+ HttpClient create(UpstreamTarget target);
+ }
+
+ /**
+ * Factory building one SmallRye {@link Guard} for a resilience shape.
+ */
+ @FunctionalInterface
+ public interface ResilienceGuardFactory {
+
+ /**
+ * @param shape the resilience shape
+ * @return the built guard
+ */
+ Guard create(ResilienceShape shape);
+ }
+}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/edge/StreamAwareRetryGate.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/edge/StreamAwareRetryGate.java
new file mode 100644
index 0000000..c67a8a1
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/edge/StreamAwareRetryGate.java
@@ -0,0 +1,75 @@
+/*
+ * 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.edge;
+
+import java.util.EnumSet;
+import java.util.Objects;
+import java.util.Set;
+
+
+import de.cuioss.sheriff.api.config.model.HttpMethod;
+
+/**
+ * The stream-aware retry gate for stage 6 upstream dispatch.
+ *
+ * A streamed request cannot be safely retried once any request body byte has crossed to the
+ * upstream — the source stream is consumed and cannot be replayed, and re-sending a partially
+ * consumed non-idempotent request risks duplicate side effects. This gate therefore permits a
+ * retry only when ALL of the following hold:
+ *
+ * - the route enables upstream retry ({@link de.cuioss.sheriff.api.routing.RouteRuntime#isRetryEnabled()});
+ * - the request method is idempotent (RFC 7231 §4.2.2: {@code GET}, {@code HEAD},
+ * {@code PUT}, {@code DELETE}, {@code OPTIONS} — never {@code POST} / {@code PATCH});
+ * - zero request body bytes have been sent to the upstream yet.
+ *
+ * The gate holds no mutable state; the per-request body-byte count is supplied by the caller.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+public final class StreamAwareRetryGate {
+
+ /** RFC 7231 §4.2.2 idempotent methods — the only verbs a partially-attempted request may retry. */
+ private static final Set IDEMPOTENT_METHODS = EnumSet.of(HttpMethod.GET, HttpMethod.HEAD, HttpMethod.PUT, HttpMethod.DELETE, HttpMethod.OPTIONS);
+
+ private final boolean routeRetryEnabled;
+
+ /**
+ * @param routeRetryEnabled the route's materialized upstream-retry toggle
+ */
+ public StreamAwareRetryGate(boolean routeRetryEnabled) {
+ this.routeRetryEnabled = routeRetryEnabled;
+ }
+
+ /**
+ * @param method the inbound request method
+ * @param bodyBytesSent the number of request body bytes already streamed to the upstream
+ * @return {@code true} only when retry is enabled, the method is idempotent, and no body byte
+ * has yet been sent
+ */
+ public boolean allowsRetry(HttpMethod method, long bodyBytesSent) {
+ Objects.requireNonNull(method, "method");
+ return routeRetryEnabled && isIdempotent(method) && bodyBytesSent == 0L;
+ }
+
+ /**
+ * @param method the request method
+ * @return {@code true} when {@code method} is an idempotent verb (RFC 7231 §4.2.2)
+ */
+ public static boolean isIdempotent(HttpMethod method) {
+ return IDEMPOTENT_METHODS.contains(Objects.requireNonNull(method, "method"));
+ }
+}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/edge/UpstreamFailureMapper.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/edge/UpstreamFailureMapper.java
new file mode 100644
index 0000000..dc18226
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/edge/UpstreamFailureMapper.java
@@ -0,0 +1,131 @@
+/*
+ * 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.edge;
+
+import java.util.Objects;
+
+
+import de.cuioss.sheriff.api.ApiSheriffLogMessages;
+import de.cuioss.sheriff.api.events.EventType;
+import de.cuioss.sheriff.api.events.GatewayEventCounter;
+import de.cuioss.sheriff.api.events.GatewayException;
+import de.cuioss.tools.logging.CuiLogger;
+
+import io.smallrye.faulttolerance.api.CircuitBreakerState;
+import org.eclipse.microprofile.faulttolerance.exceptions.CircuitBreakerOpenException;
+import org.eclipse.microprofile.faulttolerance.exceptions.TimeoutException;
+
+/**
+ * Maps an upstream dispatch failure to the gateway error contract (stage 6/7).
+ *
+ * The stage-6 dispatch runs through the route's SmallRye Fault-Tolerance guard, so a failure
+ * surfaces as one of three shapes, each with a distinct HTTP status per the error contract in
+ * {@code architecture.adoc}:
+ *
+ * - {@link CircuitBreakerOpenException} — the breaker is open, the upstream was never called →
+ * {@link EventType#UPSTREAM_CIRCUIT_OPEN} (503, {@code Retry-After} added by the edge);
+ * - a timeout ({@link TimeoutException}, {@link java.util.concurrent.TimeoutException}, or a
+ * Vert.x timeout) — the upstream exceeded its configured deadline →
+ * {@link EventType#UPSTREAM_TIMEOUT} (504);
+ * - any other connection / protocol failure → {@link EventType#UPSTREAM_ERROR} (502).
+ *
+ * Every mapped failure is metered on the shared {@link GatewayEventCounter}. Breaker state
+ * transitions are metered and logged through {@link #recordBreakerTransition(String, CircuitBreakerState)},
+ * which the boot-time edge registers with SmallRye's {@code CircuitBreakerMaintenance.onStateChange}.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+public final class UpstreamFailureMapper {
+
+ private static final CuiLogger LOGGER = new CuiLogger(UpstreamFailureMapper.class);
+
+ /** Bounds the cause-chain walk so a self-referential cause cycle cannot spin forever. */
+ private static final int MAX_CAUSE_DEPTH = 16;
+
+ private final GatewayEventCounter eventCounter;
+
+ /**
+ * @param eventCounter the shared in-process event counter feeding the metrics edge
+ */
+ public UpstreamFailureMapper(GatewayEventCounter eventCounter) {
+ this.eventCounter = Objects.requireNonNull(eventCounter, "eventCounter");
+ }
+
+ /**
+ * Classifies an upstream failure into its error-contract {@link EventType} without metering.
+ *
+ * @param failure the failure thrown by the guarded upstream dispatch
+ * @return the mapped upstream event type (502 / 503 / 504)
+ */
+ public EventType classify(Throwable failure) {
+ Objects.requireNonNull(failure, "failure");
+ Throwable current = failure;
+ for (int depth = 0; current != null && depth < MAX_CAUSE_DEPTH; depth++) {
+ if (current instanceof CircuitBreakerOpenException) {
+ return EventType.UPSTREAM_CIRCUIT_OPEN;
+ }
+ if (isTimeout(current)) {
+ return EventType.UPSTREAM_TIMEOUT;
+ }
+ Throwable cause = current.getCause();
+ if (cause == current) {
+ break;
+ }
+ current = cause;
+ }
+ return EventType.UPSTREAM_ERROR;
+ }
+
+ /**
+ * Classifies and meters an upstream failure, producing the typed {@link GatewayException} the
+ * edge renders (never leaking the internal cause to the client body).
+ *
+ * @param failure the failure thrown by the guarded upstream dispatch
+ * @return the gateway exception carrying the mapped {@link EventType}
+ */
+ public GatewayException toGatewayException(Throwable failure) {
+ EventType type = classify(failure);
+ eventCounter.increment(type);
+ return new GatewayException(type, "Upstream dispatch failed: " + type.name(), failure);
+ }
+
+ /**
+ * Meters and logs a circuit-breaker state transition. Registered by the edge with SmallRye's
+ * {@code CircuitBreakerMaintenance.onStateChange(routeId, mapper::recordBreakerTransition)}.
+ *
+ * @param routeId the route id owning the breaker
+ * @param state the new breaker state
+ */
+ public void recordBreakerTransition(String routeId, CircuitBreakerState state) {
+ Objects.requireNonNull(routeId, "routeId");
+ Objects.requireNonNull(state, "state");
+ switch (state) {
+ case OPEN -> {
+ eventCounter.increment(EventType.UPSTREAM_CIRCUIT_OPEN);
+ LOGGER.warn(ApiSheriffLogMessages.WARN.CIRCUIT_BREAKER_OPEN, routeId);
+ }
+ case CLOSED -> LOGGER.warn(ApiSheriffLogMessages.WARN.CIRCUIT_BREAKER_CLOSED, routeId);
+ default -> LOGGER.debug("Circuit breaker for route '%s' transitioned to %s", routeId, state);
+ }
+ }
+
+ private static boolean isTimeout(Throwable candidate) {
+ return candidate instanceof TimeoutException
+ || candidate instanceof java.util.concurrent.TimeoutException
+ || candidate.getClass().getSimpleName().contains("Timeout");
+ }
+}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/gateway/proxy/package-info.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/edge/package-info.java
similarity index 51%
rename from api-sheriff/src/main/java/de/cuioss/sheriff/api/gateway/proxy/package-info.java
rename to api-sheriff/src/main/java/de/cuioss/sheriff/api/edge/package-info.java
index a9fb77b..d444013 100644
--- a/api-sheriff/src/main/java/de/cuioss/sheriff/api/gateway/proxy/package-info.java
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/edge/package-info.java
@@ -14,16 +14,19 @@
* limitations under the License.
*/
/**
- * Interim minimal reverse-proxy edge for the API Sheriff gateway.
+ * The framework edge: the Vert.x-bound catch-all route, edge hardening, streamed upstream
+ * dispatch / response, and the boot-time
+ * {@link de.cuioss.sheriff.api.edge.RouteRuntimeAssembler} that compiles the frozen route
+ * table into immutable {@link de.cuioss.sheriff.api.routing.RouteRuntime} instances with
+ * deduplicated heavy collaborators.
*
- * One Vert.x route per {@code path_prefix}
- * ({@link de.cuioss.sheriff.api.gateway.proxy.ProxyRoute}) forwards matched
- * requests to the upstream resolved for that route by the configuration
- * subsystem's {@link de.cuioss.sheriff.api.config.model.RouteTable}, executing the
- * blocking forward on a virtual thread. This is the outermost shell only: it is
- * kept as the edge and has its internals replaced by the real request pipeline in
- * Plan 03.
+ * This package is framework-coupled (Vert.x, SmallRye Fault-Tolerance) and is therefore
+ * outside the ADR-0005 framework-agnostic arch-gate rule set.
*
+ * @author API Sheriff Team
* @since 1.0
*/
-package de.cuioss.sheriff.api.gateway.proxy;
+@NullMarked
+package de.cuioss.sheriff.api.edge;
+
+import org.jspecify.annotations.NullMarked;
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/events/EventCategory.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/events/EventCategory.java
new file mode 100644
index 0000000..a560e26
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/events/EventCategory.java
@@ -0,0 +1,77 @@
+/*
+ * 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.events;
+
+/**
+ * Impact grouping for gateway failure events. The category names the
+ * RFC 9457 problem type (see {@link #problemType()}) and keys the error metrics;
+ * the concrete HTTP status is carried per {@link EventType} (two events in one
+ * category may map to different statuses).
+ *
+ * {@link #CONFIGURATION} events occur at boot only — the gateway refuses to start —
+ * and never surface as an HTTP response. Success / informational events carry no
+ * category at all (see {@link EventType}).
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+public enum EventCategory {
+
+ /** Path / parameter / header pipeline, collection limits, or body-size violations. */
+ INPUT_VALIDATION("input-validation", "Input Validation"),
+
+ /** Missing or invalid bearer token, or invalid / expired session. */
+ AUTHENTICATION("authentication", "Authentication"),
+
+ /** Valid credentials lacking a required scope, or a failed CSRF origin check. */
+ AUTHORIZATION("authorization", "Authorization"),
+
+ /** Upstream connection failure, timeout, or open circuit breaker. */
+ UPSTREAM("upstream", "Upstream"),
+
+ /** Boot-time configuration failure — never rendered as an HTTP response. */
+ CONFIGURATION("configuration", "Configuration");
+
+ private final String slug;
+ private final String title;
+
+ EventCategory(String slug, String title) {
+ this.slug = slug;
+ this.title = title;
+ }
+
+ /**
+ * @return the stable, lowercase, hyphenated slug for this category
+ */
+ public String slug() {
+ return slug;
+ }
+
+ /**
+ * @return the short, human-readable title used as the RFC 9457 {@code title} member
+ */
+ public String title() {
+ return title;
+ }
+
+ /**
+ * @return the RFC 9457 {@code type} URI naming this problem category, e.g.
+ * {@code urn:api-sheriff:problem:input-validation}
+ */
+ public String problemType() {
+ return "urn:api-sheriff:problem:" + slug;
+ }
+}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/events/EventType.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/events/EventType.java
new file mode 100644
index 0000000..79758fa
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/events/EventType.java
@@ -0,0 +1,125 @@
+/*
+ * 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.events;
+
+import org.jspecify.annotations.Nullable;
+
+/**
+ * The catalogue of gateway events, each carrying its {@link EventCategory} and, for
+ * request-time failures, the HTTP status the edge renders. Modelled on
+ * {@code token-sheriff}'s {@code SecurityEventCounter.EventType}.
+ *
+ * Success / informational events carry a {@code null} category (per CUI
+ * convention) and have no HTTP mapping ({@code httpStatus == 0}) — they are metric
+ * counters only. Configuration events carry {@link EventCategory#CONFIGURATION}
+ * but also have no HTTP mapping: they occur at boot and abort startup rather than
+ * producing a response. Request-time failures carry a category and a
+ * positive HTTP status, matching the error contract in {@code architecture.adoc}.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+public enum EventType {
+
+ // --- Success / informational (no category, no HTTP mapping) ---
+
+ /** A request was successfully forwarded to its upstream. */
+ REQUEST_FORWARDED(null, 0),
+ /** A bearer token was refreshed. */
+ TOKEN_REFRESHED(null, 0),
+ /** Gateway configuration was loaded and validated at boot. */
+ CONFIG_LOADED(null, 0),
+
+ // --- Configuration failures (boot only, never an HTTP response) ---
+
+ /** Configuration failed validation; the gateway refuses to start. */
+ CONFIG_INVALID(EventCategory.CONFIGURATION, 0),
+ /** A route weakens an authentication default via an explicit override. */
+ AUTH_WEAKENED(EventCategory.CONFIGURATION, 0),
+
+ // --- Input validation (400 / 404 / 405) ---
+
+ /** A cui-http security filter rejected the request. */
+ SECURITY_FILTER_VIOLATION(EventCategory.INPUT_VALIDATION, 400),
+ /** The normalized path is outside the route's {@code allowed_paths} whitelist. */
+ PATH_NOT_ALLOWED(EventCategory.INPUT_VALIDATION, 400),
+ /** A collection / parameter limit was exceeded. */
+ PARAMETER_LIMIT_EXCEEDED(EventCategory.INPUT_VALIDATION, 400),
+ /** No route matched the canonical path (deny-by-default routing). */
+ NO_ROUTE_MATCHED(EventCategory.INPUT_VALIDATION, 404),
+ /** The request method is outside the route's effective {@code allowed_methods}. */
+ METHOD_NOT_ALLOWED(EventCategory.INPUT_VALIDATION, 405),
+
+ // --- Authentication (401) ---
+
+ /** No bearer token was presented on a route requiring one. */
+ TOKEN_MISSING(EventCategory.AUTHENTICATION, 401),
+ /** The presented bearer token was invalid or expired. */
+ TOKEN_INVALID(EventCategory.AUTHENTICATION, 401),
+
+ // --- Authorization (403) ---
+
+ /** Valid credentials lacking a required scope. */
+ SCOPE_MISSING(EventCategory.AUTHORIZATION, 403),
+ /** A CSRF origin check failed. */
+ CSRF_REJECTED(EventCategory.AUTHORIZATION, 403),
+
+ // --- Upstream (502 / 503 / 504) ---
+
+ /** Upstream connection failure or an invalid upstream response. */
+ UPSTREAM_ERROR(EventCategory.UPSTREAM, 502),
+ /** The circuit breaker is open; the upstream was not called. */
+ UPSTREAM_CIRCUIT_OPEN(EventCategory.UPSTREAM, 503),
+ /** The upstream call exceeded its configured timeout. */
+ UPSTREAM_TIMEOUT(EventCategory.UPSTREAM, 504);
+
+ private final @Nullable EventCategory category;
+ private final int httpStatus;
+
+ EventType(@Nullable EventCategory category, int httpStatus) {
+ this.category = category;
+ this.httpStatus = httpStatus;
+ }
+
+ /**
+ * @return this event's category, or {@code null} for a success / informational event
+ */
+ public @Nullable EventCategory category() {
+ return category;
+ }
+
+ /**
+ * @return the HTTP status the edge renders for this event, or {@code 0} when the event
+ * has no HTTP mapping (success / informational or boot-only configuration events)
+ */
+ public int httpStatus() {
+ return httpStatus;
+ }
+
+ /**
+ * @return {@code true} when this event represents a failure (carries a category)
+ */
+ public boolean isFailure() {
+ return category != null;
+ }
+
+ /**
+ * @return {@code true} when this event maps to an HTTP status (a request-time failure)
+ */
+ public boolean hasHttpMapping() {
+ return httpStatus > 0;
+ }
+}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/events/GatewayEventCounter.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/events/GatewayEventCounter.java
new file mode 100644
index 0000000..de36129
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/events/GatewayEventCounter.java
@@ -0,0 +1,73 @@
+/*
+ * 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.events;
+
+import java.util.Collections;
+import java.util.EnumMap;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicLong;
+
+/**
+ * Thread-safe, lock-free in-process event counter, modelled on {@code token-sheriff}'s
+ * {@code SecurityEventCounter}. It feeds the metrics and error-mapping edges without
+ * pulling in a broker, an observer bus, or a Micrometer dependency — keeping the
+ * {@code events} package framework-agnostic (ADR-0005).
+ *
+ * Increments use {@link ConcurrentHashMap#computeIfAbsent} plus
+ * {@link AtomicLong#incrementAndGet}, so concurrent callers never block one another.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+public final class GatewayEventCounter {
+
+ private final ConcurrentHashMap counters = new ConcurrentHashMap<>();
+
+ /**
+ * Atomically increments the counter for the given event type.
+ *
+ * @param eventType the event to count
+ */
+ public void increment(EventType eventType) {
+ counters.computeIfAbsent(eventType, key -> new AtomicLong()).incrementAndGet();
+ }
+
+ /**
+ * @param eventType the event to query
+ * @return the current count for {@code eventType}, or {@code 0} if never incremented
+ */
+ public long getCount(EventType eventType) {
+ AtomicLong current = counters.get(eventType);
+ return current == null ? 0L : current.get();
+ }
+
+ /**
+ * @return an unmodifiable point-in-time snapshot of all non-zero counters
+ */
+ public Map getCounters() {
+ Map snapshot = new EnumMap<>(EventType.class);
+ counters.forEach((eventType, value) -> snapshot.put(eventType, value.get()));
+ return Collections.unmodifiableMap(snapshot);
+ }
+
+ /**
+ * Resets every counter to zero. Intended for tests and controlled restarts.
+ */
+ public void reset() {
+ counters.clear();
+ }
+}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/events/GatewayException.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/events/GatewayException.java
new file mode 100644
index 0000000..17e9353
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/events/GatewayException.java
@@ -0,0 +1,70 @@
+/*
+ * 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.events;
+
+import java.io.Serial;
+
+import org.jspecify.annotations.Nullable;
+
+/**
+ * Typed gateway failure carrying the {@link EventType} that produced it. The HTTP edge
+ * reads {@link #getEventType()} to render the correct status and RFC 9457 problem type
+ * without leaking internal detail. The exception message is for logging only and
+ * is never placed in the response body.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+public class GatewayException extends RuntimeException {
+
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+ private final EventType eventType;
+
+ /**
+ * @param eventType the failure event; its {@code name()} becomes the log message
+ */
+ public GatewayException(EventType eventType) {
+ this(eventType, eventType.name());
+ }
+
+ /**
+ * @param eventType the failure event
+ * @param message the internal log message (never rendered to the client)
+ */
+ public GatewayException(EventType eventType, String message) {
+ super(message);
+ this.eventType = eventType;
+ }
+
+ /**
+ * @param eventType the failure event
+ * @param message the internal log message (never rendered to the client)
+ * @param cause the underlying cause, if any
+ */
+ public GatewayException(EventType eventType, String message, @Nullable Throwable cause) {
+ super(message, cause);
+ this.eventType = eventType;
+ }
+
+ /**
+ * @return the event type that produced this failure
+ */
+ public EventType getEventType() {
+ return eventType;
+ }
+}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/events/package-info.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/events/package-info.java
new file mode 100644
index 0000000..34c1adc
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/events/package-info.java
@@ -0,0 +1,38 @@
+/*
+ * 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.
+ */
+/**
+ * Framework-agnostic event and error model for the API Sheriff gateway.
+ *
+ * {@link de.cuioss.sheriff.api.events.EventCategory} groups failures by impact and names
+ * the RFC 9457 problem type; {@link de.cuioss.sheriff.api.events.EventType} enumerates every
+ * event with its category and (for request-time failures) HTTP status;
+ * {@link de.cuioss.sheriff.api.events.GatewayEventCounter} is the lock-free in-process
+ * counter feeding metrics and error mapping; and
+ * {@link de.cuioss.sheriff.api.events.GatewayException} is the typed failure the HTTP edge
+ * maps to a status and problem type.
+ *
+ * Framework-agnostic seam (ADR-0005). This package carries no CDI, Quarkus,
+ * Vert.x, MicroProfile, or Micrometer imports — enforced by
+ * {@code FrameworkAgnosticArchTest}. The framework-bound rendering lives in the edge and
+ * {@code quarkus} packages.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+@NullMarked
+package de.cuioss.sheriff.api.events;
+
+import org.jspecify.annotations.NullMarked;
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/forward/ForwardPolicyStage.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/forward/ForwardPolicyStage.java
new file mode 100644
index 0000000..03266b4
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/forward/ForwardPolicyStage.java
@@ -0,0 +1,165 @@
+/*
+ * 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.forward;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.function.Function;
+
+
+import de.cuioss.http.forwarded.ForwardedHeaderResolver;
+import de.cuioss.http.forwarded.ResolvedForwarding;
+import de.cuioss.sheriff.api.config.model.ForwardConfig;
+import de.cuioss.sheriff.api.pipeline.PipelineRequest;
+
+/**
+ * Stage 5 — the zero-trust forward policy, run after authentication and before upstream dispatch.
+ *
+ * The stage computes exactly what crosses to the upstream, deny-by-default:
+ *
+ * - Allowlists. Only headers named in {@code headers_allow} and query
+ * parameters named in {@code query_allow} are forwarded; everything else is dropped. Inbound
+ * {@code Authorization} crosses only when explicitly allow-listed.
+ * - Regenerated forwarding headers. Inbound {@code X-Forwarded-*} /
+ * {@code Forwarded} headers are NEVER propagated — they are regenerated through the shared
+ * {@link ForwardedHeaderResolver}, emitting {@code X-Forwarded-*} always and RFC 7239
+ * {@code Forwarded} additionally when {@code emit: both}. When the immediate TCP peer is not a
+ * {@linkplain TcpPeerGate#isTrustedPeer(String) trusted proxy}, inbound forwarding headers are
+ * ignored (a spoofed chain from an untrusted peer never influences the regenerated set).
+ * - Static set headers. {@code set_headers} are appended verbatim.
+ * - Conditional requests. {@code If-None-Match} / {@code If-Modified-Since}
+ * cross only when the route enables {@code not_modified}; otherwise they are dropped here.
+ *
+ * The stage is framework-agnostic: the immediate peer address is supplied on the
+ * {@link PipelineRequest} by the edge.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+public final class ForwardPolicyStage {
+
+ static final String EMIT_BOTH = "both";
+ private static final String FORWARDED_HEADER = "Forwarded";
+
+ private static final Set FORWARDING_HEADERS = Set.of(
+ "x-forwarded-for", "x-forwarded-host", "x-forwarded-proto", "x-forwarded-port",
+ "x-forwarded-prefix", "forwarded");
+ private static final List CONDITIONAL_HEADERS = List.of("If-None-Match", "If-Modified-Since");
+
+ private final ForwardedHeaderResolver resolver;
+ private final TcpPeerGate peerGate;
+ private final boolean emitForwarded;
+
+ /**
+ * @param resolver the shared, boot-wired forwarded-header resolver
+ * @param peerGate the immediate-TCP-peer trust gate (ADR-0003)
+ * @param emitMode the {@code forwarded.emit} mode ({@code x-forwarded} or {@code both})
+ */
+ public ForwardPolicyStage(ForwardedHeaderResolver resolver, TcpPeerGate peerGate, String emitMode) {
+ this.resolver = Objects.requireNonNull(resolver, "resolver");
+ this.peerGate = Objects.requireNonNull(peerGate, "peerGate");
+ this.emitForwarded = EMIT_BOTH.equals(Objects.requireNonNull(emitMode, "emitMode"));
+ }
+
+ /**
+ * Computes the deny-by-default upstream header and query sets.
+ *
+ * @param request the in-flight request context
+ * @param forwardConfig the selected route's {@code forward} block
+ * @param notModifiedEnabled whether the route honours conditional requests
+ * @return the forwarded headers and query parameters that cross to the upstream
+ */
+ public Result process(PipelineRequest request, ForwardConfig forwardConfig, boolean notModifiedEnabled) {
+ Objects.requireNonNull(request, "request");
+ Objects.requireNonNull(forwardConfig, "forwardConfig");
+
+ Map headers = new LinkedHashMap<>();
+ copyAllowedHeaders(request, forwardConfig, headers);
+ headers.putAll(forwardConfig.setHeaders());
+ applyConditionalHeaders(request, notModifiedEnabled, headers);
+ applyRegeneratedForwarding(request, headers);
+
+ return new Result(Map.copyOf(headers), copyAllowedQuery(request, forwardConfig));
+ }
+
+ private static void copyAllowedHeaders(PipelineRequest request, ForwardConfig forwardConfig,
+ Map headers) {
+ for (String name : forwardConfig.headersAllow()) {
+ if (FORWARDING_HEADERS.contains(name.toLowerCase(Locale.ROOT))) {
+ continue;
+ }
+ request.firstHeader(name).ifPresent(value -> headers.put(name, value));
+ }
+ }
+
+ private static void applyConditionalHeaders(PipelineRequest request, boolean notModifiedEnabled,
+ Map headers) {
+ if (!notModifiedEnabled) {
+ return;
+ }
+ for (String name : CONDITIONAL_HEADERS) {
+ request.firstHeader(name).ifPresent(value -> headers.put(name, value));
+ }
+ }
+
+ private void applyRegeneratedForwarding(PipelineRequest request, Map headers) {
+ boolean peerTrusted = peerGate.isTrustedPeer(request.peerAddress());
+ Function lookup = name -> {
+ if (!peerTrusted && FORWARDING_HEADERS.contains(name.toLowerCase(Locale.ROOT))) {
+ return null;
+ }
+ return request.firstHeader(name).orElse(null);
+ };
+ ResolvedForwarding resolved = resolver.resolve(lookup);
+ headers.putAll(resolved.toXForwardedHeaders());
+ if (emitForwarded) {
+ resolved.toForwardedHeader().ifPresent(value -> headers.put(FORWARDED_HEADER, value));
+ }
+ }
+
+ private static Map> copyAllowedQuery(PipelineRequest request, ForwardConfig forwardConfig) {
+ Map> query = new LinkedHashMap<>();
+ for (String name : forwardConfig.queryAllow()) {
+ List values = request.queryParameters().get(name);
+ if (values != null && !values.isEmpty()) {
+ query.put(name, List.copyOf(values));
+ }
+ }
+ return Map.copyOf(query);
+ }
+
+ /**
+ * The computed upstream request projection: the regenerated, allow-listed headers and the
+ * allow-listed query parameters that cross to the upstream.
+ *
+ * @param headers the outbound header set (allow-listed + set_headers + regenerated forwarding)
+ * @param query the outbound query parameters (allow-listed only)
+ */
+ public record Result(Map headers, Map> query) {
+
+ /**
+ * Canonical constructor defensively copying the collections.
+ */
+ public Result {
+ headers = Map.copyOf(headers);
+ query = Map.copyOf(query);
+ }
+ }
+}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/forward/TcpPeerGate.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/forward/TcpPeerGate.java
new file mode 100644
index 0000000..1f2df9b
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/forward/TcpPeerGate.java
@@ -0,0 +1,132 @@
+/*
+ * 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.forward;
+
+import java.net.InetAddress;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+
+import org.jspecify.annotations.Nullable;
+
+/**
+ * The gateway-side immediate-TCP-peer trust gate (ADR-0003) — API Sheriff code, deliberately not
+ * delegated to the forwarded-header resolver.
+ *
+ * The {@code forwarded.trusted_proxies} CIDR set is parsed once, at boot, into a matcher list; at
+ * request time only the single immediate peer address is parsed (no per-request CIDR string
+ * parsing). {@link #isTrustedPeer(String)} answers whether the immediate TCP peer sits inside the
+ * trusted set, which the {@link ForwardPolicyStage} consults before it honours any inbound
+ * forwarding header — a spoofed {@code X-Forwarded-For} from an untrusted peer is therefore ignored.
+ * The gate fails closed: an empty trusted set trusts no peer, and an unparseable peer address is
+ * never trusted.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+public final class TcpPeerGate {
+
+ private final List trustedRanges;
+
+ /**
+ * @param trustedProxyCidrs the boot-parsed {@code forwarded.trusted_proxies} CIDR entries
+ */
+ public TcpPeerGate(List trustedProxyCidrs) {
+ Objects.requireNonNull(trustedProxyCidrs, "trustedProxyCidrs");
+ List ranges = new ArrayList<>();
+ for (String cidr : trustedProxyCidrs) {
+ ranges.add(Cidr.parse(cidr));
+ }
+ this.trustedRanges = List.copyOf(ranges);
+ }
+
+ /**
+ * @param peerAddress the immediate TCP peer address, {@code null} when the edge supplied none
+ * @return {@code true} when the peer falls inside a trusted CIDR range
+ */
+ public boolean isTrustedPeer(@Nullable String peerAddress) {
+ if (peerAddress == null || trustedRanges.isEmpty()) {
+ return false;
+ }
+ InetAddress peer;
+ try {
+ peer = InetAddress.ofLiteral(peerAddress.strip());
+ } catch (IllegalArgumentException notALiteral) {
+ return false;
+ }
+ for (Cidr range : trustedRanges) {
+ if (range.contains(peer)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * A boot-parsed CIDR range: the network prefix bytes and the significant-bit count. Matching is
+ * address-family aware — an IPv4 candidate never matches an IPv6 range and vice versa.
+ *
+ * The network address is held as an immutable {@link List} of bytes rather than a raw
+ * {@code byte[]} so the record's generated {@code equals} / {@code hashCode} / {@code toString}
+ * are content-aware (an array component would compare by identity, which is misleading).
+ *
+ * @param network the network address bytes (4 for IPv4, 16 for IPv6)
+ * @param prefixLength the number of significant leading bits
+ */
+ private record Cidr(List network, int prefixLength) {
+
+ static Cidr parse(String cidr) {
+ Objects.requireNonNull(cidr, "cidr");
+ int slash = cidr.indexOf('/');
+ if (slash < 0) {
+ throw new IllegalArgumentException("Not a CIDR range: " + cidr);
+ }
+ byte[] networkBytes = InetAddress.ofLiteral(cidr.substring(0, slash).strip()).getAddress();
+ int prefixLength = Integer.parseInt(cidr.substring(slash + 1).strip());
+ if (prefixLength < 0 || prefixLength > networkBytes.length * 8) {
+ throw new IllegalArgumentException("CIDR prefix out of range: " + cidr);
+ }
+ return new Cidr(toImmutableByteList(networkBytes), prefixLength);
+ }
+
+ boolean contains(InetAddress candidate) {
+ byte[] address = candidate.getAddress();
+ if (address.length != network.size()) {
+ return false;
+ }
+ int fullBytes = prefixLength / 8;
+ for (int i = 0; i < fullBytes; i++) {
+ if (address[i] != network.get(i)) {
+ return false;
+ }
+ }
+ int remainingBits = prefixLength % 8;
+ if (remainingBits == 0) {
+ return true;
+ }
+ int mask = (0xFF << (8 - remainingBits)) & 0xFF;
+ return (address[fullBytes] & mask) == (network.get(fullBytes) & mask);
+ }
+
+ private static List toImmutableByteList(byte[] bytes) {
+ List list = new ArrayList<>(bytes.length);
+ for (byte value : bytes) {
+ list.add(value);
+ }
+ return List.copyOf(list);
+ }
+ }
+}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/forward/package-info.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/forward/package-info.java
new file mode 100644
index 0000000..c2800d5
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/forward/package-info.java
@@ -0,0 +1,38 @@
+/*
+ * 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.
+ */
+/**
+ * Stage 5 — the zero-trust forward policy.
+ *
+ * {@link de.cuioss.sheriff.api.forward.ForwardPolicyStage} computes the deny-by-default upstream
+ * projection — allow-listed headers and query parameters, static {@code set_headers}, and
+ * regenerated forwarding headers (inbound {@code X-Forwarded-*} / {@code Forwarded} are
+ * never propagated) — through the shared cui-http forwarded-header resolver.
+ * {@link de.cuioss.sheriff.api.forward.TcpPeerGate} is the gateway-side immediate-TCP-peer trust
+ * gate (ADR-0003) over the boot-parsed {@code trusted_proxies} CIDR set.
+ *
+ * Framework-agnostic. The package operates on the agnostic
+ * {@link de.cuioss.sheriff.api.pipeline.PipelineRequest} (the immediate peer address is supplied by
+ * the edge) and carries no {@code io.vertx..} / {@code io.quarkus..} / {@code jakarta..} /
+ * {@code org.eclipse.microprofile..} / {@code io.micrometer..} imports, so it stays inside the
+ * ADR-0005 framework-agnostic arch-gate rule set.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+@NullMarked
+package de.cuioss.sheriff.api.forward;
+
+import org.jspecify.annotations.NullMarked;
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/gateway/GatewayResource.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/gateway/GatewayResource.java
deleted file mode 100644
index 303139a..0000000
--- a/api-sheriff/src/main/java/de/cuioss/sheriff/api/gateway/GatewayResource.java
+++ /dev/null
@@ -1,79 +0,0 @@
-/*
- * 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.gateway;
-
-import de.cuioss.sheriff.api.ApiSheriff;
-import io.quarkus.runtime.annotations.RegisterForReflection;
-import jakarta.enterprise.context.ApplicationScoped;
-import jakarta.inject.Inject;
-import jakarta.ws.rs.GET;
-import jakarta.ws.rs.Path;
-import jakarta.ws.rs.Produces;
-import jakarta.ws.rs.core.MediaType;
-import jakarta.ws.rs.core.Response;
-
-/**
- * REST resource for the API Sheriff gateway providing health and info endpoints.
- *
- * @author API Sheriff Team
- * @since 1.0
- */
-@Path("/api")
-@ApplicationScoped
-@RegisterForReflection
-@Produces(MediaType.APPLICATION_JSON)
-public class GatewayResource {
-
- private final ApiSheriff apiSheriff;
-
- /**
- * Creates a new GatewayResource with the given ApiSheriff instance.
- *
- * @param apiSheriff the ApiSheriff instance to use
- */
- @Inject
- public GatewayResource(ApiSheriff apiSheriff) {
- this.apiSheriff = apiSheriff;
- }
-
- /**
- * Health check endpoint to verify that API Sheriff is properly configured.
- *
- * @return Response indicating the health status of API Sheriff
- */
- @GET
- @Path("/health")
- @Produces(MediaType.APPLICATION_JSON)
- public Response health() {
- String status = apiSheriff.getStatus();
-
- return Response.ok("{\"status\":\"UP\",\"apiSheriff\":\"" + status + "\"}")
- .build();
- }
-
- /**
- * Info endpoint for basic API Sheriff status.
- *
- * @return Response with application information
- */
- @GET
- @Path("/info")
- @Produces(MediaType.APPLICATION_JSON)
- public Response info() {
- return Response.ok("{\"message\":\"API Sheriff Gateway\",\"version\":\"1.0.0-SNAPSHOT\"}")
- .build();
- }
-}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/gateway/package-info.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/gateway/package-info.java
deleted file mode 100644
index d971fd9..0000000
--- a/api-sheriff/src/main/java/de/cuioss/sheriff/api/gateway/package-info.java
+++ /dev/null
@@ -1,24 +0,0 @@
-/*
- * 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.
- */
-/**
- * Gateway REST endpoints for the API Sheriff application.
- *
- * This package contains the JAX-RS resources that serve as the external API
- * for the API Sheriff gateway, including health and info endpoints.
- *
- * @since 1.0
- */
-package de.cuioss.sheriff.api.gateway;
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/gateway/proxy/ProxyLogMessages.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/gateway/proxy/ProxyLogMessages.java
deleted file mode 100644
index 263a1ea..0000000
--- a/api-sheriff/src/main/java/de/cuioss/sheriff/api/gateway/proxy/ProxyLogMessages.java
+++ /dev/null
@@ -1,65 +0,0 @@
-/*
- * 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.gateway.proxy;
-
-import de.cuioss.tools.logging.LogRecord;
-import de.cuioss.tools.logging.LogRecordModel;
-import lombok.experimental.UtilityClass;
-
-/**
- * DSL-style {@link LogRecord} catalogue for the API Sheriff proxy edge.
- *
- * Structured {@code INFO} / {@code WARN} / {@code ERROR} messages carry the
- * {@code ApiSheriff} prefix and a stable numeric identifier, so they are
- * greppable and assertable. {@code DEBUG} / {@code TRACE} diagnostics use the
- * logger directly and are not catalogued here.
- *
- * @author API Sheriff Team
- * @since 1.0
- */
-@UtilityClass
-public final class ProxyLogMessages {
-
- private static final String PREFIX = "ApiSheriff";
-
- /**
- * Info-level messages (INFO range 1-99).
- */
- @UtilityClass
- public static final class INFO {
-
- /** The catch-all proxy route was registered on the data plane. */
- public static final LogRecord ROUTE_REGISTERED = LogRecordModel.builder()
- .prefix(PREFIX)
- .identifier(1)
- .template("Proxy route registered: path-prefix='%s' upstream='%s'")
- .build();
- }
-
- /**
- * Warn-level messages (WARN range 100-199).
- */
- @UtilityClass
- public static final class WARN {
-
- /** Forwarding a request to the configured upstream failed. */
- public static final LogRecord UPSTREAM_FAILURE = LogRecordModel.builder()
- .prefix(PREFIX)
- .identifier(100)
- .template("Proxy request to upstream '%s' failed: %s")
- .build();
- }
-}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/gateway/proxy/ProxyRoute.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/gateway/proxy/ProxyRoute.java
deleted file mode 100644
index f96ddf4..0000000
--- a/api-sheriff/src/main/java/de/cuioss/sheriff/api/gateway/proxy/ProxyRoute.java
+++ /dev/null
@@ -1,266 +0,0 @@
-/*
- * 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.gateway.proxy;
-
-import java.io.IOException;
-import java.net.URI;
-import java.net.http.HttpClient;
-import java.net.http.HttpRequest;
-import java.net.http.HttpResponse;
-import java.time.Duration;
-import java.util.Optional;
-import java.util.Set;
-import java.util.concurrent.ExecutorService;
-
-import de.cuioss.sheriff.api.config.model.ResolvedRoute;
-import de.cuioss.sheriff.api.config.model.ResolvedUpstream;
-import de.cuioss.sheriff.api.config.model.RouteTable;
-import de.cuioss.sheriff.api.gateway.proxy.ProxyLogMessages.INFO;
-import de.cuioss.sheriff.api.gateway.proxy.ProxyLogMessages.WARN;
-import de.cuioss.tools.logging.CuiLogger;
-import io.quarkus.virtual.threads.VirtualThreads;
-import io.vertx.core.buffer.Buffer;
-import io.vertx.core.http.HttpServerRequest;
-import io.vertx.core.http.HttpServerResponse;
-import io.vertx.ext.web.Router;
-import io.vertx.ext.web.RoutingContext;
-import io.vertx.ext.web.handler.BodyHandler;
-import jakarta.enterprise.context.ApplicationScoped;
-import jakarta.enterprise.event.Observes;
-import jakarta.inject.Inject;
-
-/**
- * Interim minimal reverse proxy: a Vert.x route per configured
- * {@code path_prefix} that forwards matched requests to the upstream resolved for
- * that route.
- *
- * The routing table is the {@link RouteTable} assembled by the configuration
- * subsystem (Deliverable 7); this edge sources each route's upstream from the
- * table's {@link ResolvedUpstream} rather than from a single static configuration
- * bean. Behaviour (deliberately interim — Plan 03 keeps this edge and replaces the
- * internals with the real request pipeline):
- *
- * - One route is registered per {@code path_prefix} in the table; the prefix
- * is stripped and the remaining path plus the query string are appended to
- * the route's upstream base URL.
- * - Method, path remainder, query and body pass through; hop-by-hop headers
- * are stripped in both directions.
- * - Forwarding uses a blocking JDK {@link HttpClient} {@code send()} executed
- * on a virtual thread, so the Vert.x event loop is never blocked.
- * - Any path with no matching route falls through to {@code 404}
- * (deny-by-default); upstream failures surface as {@code 502}.
- *
- *
- * @author API Sheriff Team
- * @since 1.0
- */
-@ApplicationScoped
-public class ProxyRoute {
-
- private static final CuiLogger LOGGER = new CuiLogger(ProxyRoute.class);
-
- /**
- * Hop-by-hop headers (RFC 7230 §6.1) plus JDK {@link HttpClient} restricted
- * request headers, which must not be forwarded to the upstream. All lower case.
- */
- private static final Set REQUEST_SKIP_HEADERS = Set.of(
- "connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
- "te", "trailer", "transfer-encoding", "upgrade",
- "host", "content-length", "expect");
-
- /**
- * Hop-by-hop headers plus length/framing headers that Vert.x recomputes when
- * the response body is written. All lower case.
- */
- private static final Set RESPONSE_SKIP_HEADERS = Set.of(
- "connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
- "te", "trailer", "transfer-encoding", "upgrade", "content-length");
-
- /**
- * Disposable stopgap mitigation: cap the buffered request body the interim
- * proxy accepts, so an unbounded upload cannot exhaust heap before Plan 04
- * replaces this edge with the real streaming pipeline. An over-limit body is
- * rejected by the Vert.x {@link BodyHandler} with {@code 413 Payload Too Large}.
- */
- private static final long MAX_REQUEST_BODY_BYTES = 1024L * 1024L;
-
- /**
- * Disposable stopgap mitigation: bound each upstream request so a slow or hung
- * upstream cannot pin a virtual thread indefinitely. On expiry the JDK
- * {@link HttpClient} throws {@link java.net.http.HttpTimeoutException} (an
- * {@link IOException}), which surfaces to the client as {@code 502}. Superseded
- * by Plan 04's timeouts-from-config.
- */
- private static final Duration UPSTREAM_REQUEST_TIMEOUT = Duration.ofSeconds(30);
-
- /**
- * Test-only seam: JVM system property that overrides {@link #UPSTREAM_REQUEST_TIMEOUT}
- * (value in milliseconds) so the timeout-trips behaviour can be exercised without a
- * multi-second wait. This is a throwaway JVM property, NOT gateway configuration —
- * config-driven upstream timeouts are Plan 04's edge and are deliberately not added here.
- */
- private static final String UPSTREAM_REQUEST_TIMEOUT_MS_PROPERTY =
- "apisheriff.proxy.upstream-request-timeout-ms";
-
- private final RouteTable routeTable;
- private final ExecutorService virtualThreadExecutor;
- private final HttpClient httpClient;
-
- /**
- * Creates the proxy route.
- *
- * @param routeTable the assembled route table sourcing each route's
- * upstream
- * @param virtualThreadExecutor the Quarkus-managed virtual-thread executor
- * (thread names use {@code quarkus.virtual-threads.name-prefix})
- */
- @Inject
- public ProxyRoute(RouteTable routeTable, @VirtualThreads ExecutorService virtualThreadExecutor) {
- this.routeTable = routeTable;
- this.virtualThreadExecutor = virtualThreadExecutor;
- this.httpClient = HttpClient.newBuilder()
- .version(HttpClient.Version.HTTP_1_1)
- .connectTimeout(Duration.ofSeconds(10))
- .followRedirects(HttpClient.Redirect.NEVER)
- .build();
- }
-
- /**
- * Registers one catch-all proxy route per {@code path_prefix} in the route
- * table on the data-plane router at startup.
- *
- * @param router the Vert.x web router, observed during Quarkus startup
- */
- public void registerRoutes(@Observes Router router) {
- for (ResolvedRoute route : routeTable.routes()) {
- String routePath = stripTrailingSlash(route.pathPrefix()) + "/*";
- router.route(routePath)
- .handler(BodyHandler.create().setBodyLimit(MAX_REQUEST_BODY_BYTES))
- .handler(ctx -> virtualThreadExecutor.execute(() -> forward(ctx)));
- LOGGER.info(INFO.ROUTE_REGISTERED, route.pathPrefix(), upstreamBaseUrl(route.upstream()));
- }
- }
-
- /**
- * Forwards a single request to the upstream resolved for its matching route.
- * Runs on a virtual thread; the response is written back on the Vert.x context.
- *
- * @param ctx the routing context of the matched request
- */
- private void forward(RoutingContext ctx) {
- String rawUri = ctx.request().uri();
- int queryStart = rawUri.indexOf('?');
- String rawPath = queryStart < 0 ? rawUri : rawUri.substring(0, queryStart);
- String rawQuery = queryStart < 0 ? "" : rawUri.substring(queryStart);
-
- Optional match = routeTable.lookup(rawPath);
- if (match.isEmpty()) {
- ctx.vertx().runOnContext(v -> {
- if (!ctx.response().ended()) {
- ctx.response().setStatusCode(404).end();
- }
- });
- return;
- }
-
- String prefix = stripTrailingSlash(match.get().pathPrefix());
- String upstreamBaseUrl = upstreamBaseUrl(match.get().upstream());
- // Everything that can throw (substring, URI parsing, send) stays inside the
- // try so any failure yields a 502 rather than an uncaught error that would
- // leave the client hanging on the virtual thread.
- String target = upstreamBaseUrl;
- try {
- String remainder = rawPath.substring(prefix.length());
- target = upstreamBaseUrl + remainder + rawQuery;
-
- HttpRequest upstreamRequest = buildUpstreamRequest(ctx, target);
- HttpResponse upstreamResponse = httpClient.send(upstreamRequest, HttpResponse.BodyHandlers.ofByteArray());
- ctx.vertx().runOnContext(v -> writeUpstreamResponse(ctx, upstreamResponse));
- } catch (InterruptedException e) {
- Thread.currentThread().interrupt();
- failBadGateway(ctx, target, e);
- } catch (IOException | IllegalArgumentException | IndexOutOfBoundsException e) {
- failBadGateway(ctx, target, e);
- }
- }
-
- private HttpRequest buildUpstreamRequest(RoutingContext ctx, String target) {
- HttpServerRequest request = ctx.request();
- Buffer body = ctx.body() == null ? null : ctx.body().buffer();
- HttpRequest.BodyPublisher bodyPublisher = body == null || body.length() == 0
- ? HttpRequest.BodyPublishers.noBody()
- : HttpRequest.BodyPublishers.ofByteArray(body.getBytes());
-
- HttpRequest.Builder builder = HttpRequest.newBuilder(URI.create(target))
- .timeout(resolveUpstreamRequestTimeout());
- request.headers().forEach(header -> {
- if (!REQUEST_SKIP_HEADERS.contains(header.getKey().toLowerCase())) {
- builder.header(header.getKey(), header.getValue());
- }
- });
- return builder.method(request.method().name(), bodyPublisher).build();
- }
-
- private void writeUpstreamResponse(RoutingContext ctx, HttpResponse upstreamResponse) {
- HttpServerResponse response = ctx.response();
- response.setStatusCode(upstreamResponse.statusCode());
- upstreamResponse.headers().map().forEach((name, values) -> {
- if (!RESPONSE_SKIP_HEADERS.contains(name.toLowerCase())) {
- // headers().add (not putHeader) so multi-valued headers such as
- // Set-Cookie are all preserved rather than collapsed to the last.
- values.forEach(value -> response.headers().add(name, value));
- }
- });
- response.end(Buffer.buffer(upstreamResponse.body()));
- }
-
- private void failBadGateway(RoutingContext ctx, String target, Exception cause) {
- LOGGER.warn(cause, WARN.UPSTREAM_FAILURE, target, cause.getMessage());
- ctx.vertx().runOnContext(v -> {
- if (!ctx.response().ended()) {
- ctx.response().setStatusCode(502).end();
- }
- });
- }
-
- private static String upstreamBaseUrl(ResolvedUpstream upstream) {
- return stripTrailingSlash("%s://%s:%d%s".formatted(upstream.scheme(), upstream.host(), upstream.port(),
- upstream.basePath()));
- }
-
- private static String stripTrailingSlash(String value) {
- return value.endsWith("/") ? value.substring(0, value.length() - 1) : value;
- }
-
- /**
- * Resolves the upstream request timeout, honouring the {@value #UPSTREAM_REQUEST_TIMEOUT_MS_PROPERTY}
- * test-only override when set and falling back to {@link #UPSTREAM_REQUEST_TIMEOUT} otherwise.
- * {@link Long#getLong(String, long)} returns the default when the property is absent or unparseable.
- * A non-positive configured value (0 or negative) is rejected in favour of the default, because
- * {@link HttpRequest.Builder#timeout(Duration)} throws {@link IllegalArgumentException} for a
- * non-positive duration — which would otherwise surface as a {@code 502} on every proxied request
- * rather than as a clear misconfiguration fallback.
- * Read per request so a test can set the override immediately before a call without depending on
- * class-load or Quarkus-boot ordering.
- */
- private static Duration resolveUpstreamRequestTimeout() {
- long configuredMillis = Long.getLong(UPSTREAM_REQUEST_TIMEOUT_MS_PROPERTY, UPSTREAM_REQUEST_TIMEOUT.toMillis());
- if (configuredMillis <= 0) {
- return UPSTREAM_REQUEST_TIMEOUT;
- }
- return Duration.ofMillis(configuredMillis);
- }
-}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/pipeline/BasicChecksStage.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/pipeline/BasicChecksStage.java
new file mode 100644
index 0000000..5356133
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/pipeline/BasicChecksStage.java
@@ -0,0 +1,134 @@
+/*
+ * 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.pipeline;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+
+import de.cuioss.http.security.config.SecurityConfiguration;
+import de.cuioss.http.security.exceptions.UrlSecurityException;
+import de.cuioss.http.security.monitoring.SecurityEventCounter;
+import de.cuioss.http.security.pipeline.PipelineFactory;
+import de.cuioss.sheriff.api.events.EventType;
+import de.cuioss.sheriff.api.events.GatewayException;
+
+/**
+ * Stage 1 — the baseline cui-http security filter plus collection-limit fast-reject, run for
+ * every request before route selection.
+ *
+ * The stage validates the raw path, every query-parameter value, and every header name / value
+ * through the shared {@link PipelineFactory.PipelineSet} built from the gateway's default
+ * {@link SecurityConfiguration}. The path pipeline yields the single canonical path
+ * ({@link PipelineRequest#canonicalPath(String)}) that GW-01 requires every later stage to consume.
+ * A pipeline violation ({@link UrlSecurityException}) becomes a
+ * {@link EventType#SECURITY_FILTER_VIOLATION} (400); a parameter- or header-count overflow beyond
+ * the configured caps becomes a {@link EventType#PARAMETER_LIMIT_EXCEEDED} (400) — both without ever
+ * echoing the offending value.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+public final class BasicChecksStage {
+
+ private final SecurityConfiguration configuration;
+ private final PipelineFactory.PipelineSet pipelines;
+
+ /**
+ * @param configuration the gateway's default inbound validation policy
+ * @param eventCounter the shared cui-http security event counter (never a local instance)
+ */
+ public BasicChecksStage(SecurityConfiguration configuration, SecurityEventCounter eventCounter) {
+ this.configuration = Objects.requireNonNull(configuration, "configuration");
+ this.pipelines = PipelineFactory.createCommonPipelines(configuration,
+ Objects.requireNonNull(eventCounter, "eventCounter"));
+ }
+
+ /**
+ * Runs the baseline filter, records the canonical path, and enforces collection caps.
+ *
+ * @param request the in-flight request context
+ * @throws GatewayException with {@link EventType#SECURITY_FILTER_VIOLATION} on a pipeline
+ * violation, or {@link EventType#PARAMETER_LIMIT_EXCEEDED} on a
+ * parameter- or header-count overflow
+ */
+ public void process(PipelineRequest request) {
+ Objects.requireNonNull(request, "request");
+ enforceCollectionLimits(request);
+ String canonical = validatePath(request.requestPath());
+ validateParameters(request.queryParameters());
+ validateHeaders(request.headers());
+ request.canonicalPath(canonical);
+ }
+
+ private void enforceCollectionLimits(PipelineRequest request) {
+ long paramCount = request.queryParameters().values().stream().mapToLong(List::size).sum();
+ if (paramCount > configuration.maxParameterCount()) {
+ throw new GatewayException(EventType.PARAMETER_LIMIT_EXCEEDED,
+ "Query-parameter count %d exceeds cap %d".formatted(paramCount, configuration.maxParameterCount()));
+ }
+ long headerCount = request.headers().values().stream().mapToLong(List::size).sum();
+ if (headerCount > configuration.maxHeaderCount()) {
+ throw new GatewayException(EventType.PARAMETER_LIMIT_EXCEEDED,
+ "Header count %d exceeds cap %d".formatted(headerCount, configuration.maxHeaderCount()));
+ }
+ }
+
+ private String validatePath(String rawPath) {
+ try {
+ return pipelines.urlPathPipeline().validate(rawPath).orElse(rawPath);
+ } catch (UrlSecurityException violation) {
+ throw rejected(violation);
+ }
+ }
+
+ private void validateParameters(Map> parameters) {
+ try {
+ for (Map.Entry> parameter : parameters.entrySet()) {
+ // Validate the parameter NAME as well as each value. cui-http exposes no dedicated
+ // URL-parameter-name pipeline to this project, so the url-parameter pipeline is reused
+ // against the key — closing the name-validation gap with the same rigor applied to
+ // values, mirroring validateHeaders which validates both header name and value.
+ pipelines.urlParameterPipeline().validate(parameter.getKey());
+ for (String value : parameter.getValue()) {
+ pipelines.urlParameterPipeline().validate(value);
+ }
+ }
+ } catch (UrlSecurityException violation) {
+ throw rejected(violation);
+ }
+ }
+
+ private void validateHeaders(Map> headers) {
+ try {
+ for (Map.Entry> header : headers.entrySet()) {
+ pipelines.headerNamePipeline().validate(header.getKey());
+ for (String value : header.getValue()) {
+ pipelines.headerValuePipeline().validate(value);
+ }
+ }
+ } catch (UrlSecurityException violation) {
+ throw rejected(violation);
+ }
+ }
+
+ private static GatewayException rejected(UrlSecurityException violation) {
+ return new GatewayException(EventType.SECURITY_FILTER_VIOLATION,
+ "Security filter rejected %s at %s".formatted(violation.getFailureType(), violation.getValidationType()),
+ violation);
+ }
+}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/pipeline/CanonicalPathGuard.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/pipeline/CanonicalPathGuard.java
new file mode 100644
index 0000000..2761740
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/pipeline/CanonicalPathGuard.java
@@ -0,0 +1,75 @@
+/*
+ * 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.pipeline;
+
+import java.util.Locale;
+import java.util.Objects;
+
+
+import de.cuioss.sheriff.api.events.EventType;
+import de.cuioss.sheriff.api.events.GatewayException;
+
+/**
+ * D3b GW-01 single-canonical-path guard, run at stage 1.
+ *
+ * The GW-01 threat is a decoding split: the gateway authorizes one interpretation of a path while
+ * the upstream dispatches a different one, letting a request slip past route/verb-gate/auth/whitelist
+ * checks. The guard closes it by rejecting the two constructs that make a path multi-valued —
+ * an encoded path separator ({@code %2f} / {@code %2F}, and the encoded backslash
+ * {@code %5c} / {@code %5C}) and a matrix parameter ({@code ;}) — with a 400
+ * {@link EventType#SECURITY_FILTER_VIOLATION}. The guard inspects the raw inbound path (before
+ * normalization can hide the ambiguity) and additionally asserts that stage 1 recorded exactly one
+ * {@link PipelineRequest#canonicalPath() canonical path}, which every later stage then consumes.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+public final class CanonicalPathGuard {
+
+ private static final String ENCODED_SLASH = "%2f";
+ private static final String ENCODED_BACKSLASH = "%5c";
+ private static final char MATRIX_PARAM = ';';
+
+ /**
+ * Rejects ambiguous path encodings and asserts the single-canonical-path invariant.
+ *
+ * @param request the in-flight request context; its canonical path must be set (stage 1)
+ * @throws GatewayException with {@link EventType#SECURITY_FILTER_VIOLATION} on an encoded
+ * separator or a matrix parameter
+ */
+ public void process(PipelineRequest request) {
+ Objects.requireNonNull(request, "request");
+ String rawPath = request.requestPath();
+ String lowered = rawPath.toLowerCase(Locale.ROOT);
+ if (lowered.contains(ENCODED_SLASH)) {
+ throw violation("encoded path separator %2f");
+ }
+ if (lowered.contains(ENCODED_BACKSLASH)) {
+ throw violation("encoded backslash %5c");
+ }
+ if (rawPath.indexOf(MATRIX_PARAM) >= 0) {
+ throw violation("matrix parameter ';'");
+ }
+ if (request.canonicalPath() == null) {
+ throw new IllegalStateException("Canonical path guard requires the canonical path resolved at stage 1");
+ }
+ }
+
+ private static GatewayException violation(String detail) {
+ return new GatewayException(EventType.SECURITY_FILTER_VIOLATION,
+ "Non-canonical path rejected: " + detail);
+ }
+}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/pipeline/FramingGate.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/pipeline/FramingGate.java
new file mode 100644
index 0000000..ea16a7f
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/pipeline/FramingGate.java
@@ -0,0 +1,111 @@
+/*
+ * 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.pipeline;
+
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Objects;
+import java.util.Set;
+
+
+import de.cuioss.sheriff.api.config.model.HttpMethod;
+import de.cuioss.sheriff.api.events.EventType;
+import de.cuioss.sheriff.api.events.GatewayException;
+
+/**
+ * D3b GW-02 anti-request-smuggling / framing gate, run at stage 1 and re-runnable after any
+ * header mutation.
+ *
+ * The gate rejects the three framing-desync vectors with a 400
+ * {@link EventType#SECURITY_FILTER_VIOLATION} before a request can reach the upstream:
+ *
+ * - CL+TE: {@code Content-Length} and {@code Transfer-Encoding} both present,
+ * the classic front-end/back-end desync primer;
+ * - body on a bodyless method: a declared body (or {@code Transfer-Encoding})
+ * on {@code GET} or {@code HEAD};
+ * - framing/trust-header strip via {@code Connection}: a {@code Connection}
+ * token naming a framing header ({@code Content-Length} / {@code Transfer-Encoding} /
+ * {@code Host}) or a trust header ({@code Authorization} / {@code Forwarded} /
+ * {@code X-Forwarded-*}), which would drop that header hop-by-hop and reopen the desync.
+ *
+ * Because the gate is stateless it is safe to re-invoke after stage 5 regenerates forwarding
+ * headers, re-asserting framing integrity on the mutated header set.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+public final class FramingGate {
+
+ private static final Set BODYLESS_METHODS = EnumSet.of(HttpMethod.GET, HttpMethod.HEAD);
+
+ private static final Set PROTECTED_HEADERS = Set.of(
+ "content-length", "transfer-encoding", "host",
+ "authorization", "forwarded",
+ "x-forwarded-for", "x-forwarded-host", "x-forwarded-proto", "x-forwarded-port");
+
+ /**
+ * Re-asserts framing integrity on the current header set.
+ *
+ * @param request the in-flight request context
+ * @throws GatewayException with {@link EventType#SECURITY_FILTER_VIOLATION} on any framing vector
+ */
+ public void process(PipelineRequest request) {
+ Objects.requireNonNull(request, "request");
+ rejectConflictingFraming(request);
+ rejectBodyOnBodylessMethod(request);
+ rejectFramingHeaderStrip(request);
+ }
+
+ private static void rejectConflictingFraming(PipelineRequest request) {
+ // RFC 7230 §3.3.2: a message carrying more than one Content-Length field — whether sent as
+ // multiple Content-Length headers or as a single field with a comma-separated value list —
+ // is ambiguous and MUST be rejected, since it is a classic HTTP request-smuggling vector.
+ // This is checked before the CL+TE coexistence rule below.
+ List contentLengths = request.headerValues("Content-Length");
+ if (contentLengths.size() > 1) {
+ throw violation("Multiple Content-Length headers present");
+ }
+ if (!contentLengths.isEmpty() && contentLengths.getFirst().indexOf(',') >= 0) {
+ throw violation("Content-Length header carries a comma-separated value list");
+ }
+ if (request.hasHeader("Content-Length") && request.hasHeader("Transfer-Encoding")) {
+ throw violation("Content-Length and Transfer-Encoding both present");
+ }
+ }
+
+ private static void rejectBodyOnBodylessMethod(PipelineRequest request) {
+ if (BODYLESS_METHODS.contains(request.method())
+ && (request.bodyPresent() || request.declaredContentLength() > 0
+ || request.hasHeader("Transfer-Encoding"))) {
+ throw violation("Body present on bodyless method " + request.method());
+ }
+ }
+
+ private static void rejectFramingHeaderStrip(PipelineRequest request) {
+ for (String connectionValue : request.headerValues("Connection")) {
+ for (String token : connectionValue.split(",")) {
+ if (PROTECTED_HEADERS.contains(token.strip().toLowerCase(Locale.ROOT))) {
+ throw violation("Connection header attempts to strip protected header " + token.strip());
+ }
+ }
+ }
+ }
+
+ private static GatewayException violation(String detail) {
+ return new GatewayException(EventType.SECURITY_FILTER_VIOLATION, "Framing rejected: " + detail);
+ }
+}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/pipeline/PipelineRequest.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/pipeline/PipelineRequest.java
new file mode 100644
index 0000000..6f3840c
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/pipeline/PipelineRequest.java
@@ -0,0 +1,351 @@
+/*
+ * 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.pipeline;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+
+
+import de.cuioss.sheriff.api.config.model.HttpMethod;
+import de.cuioss.sheriff.api.routing.RouteRuntime;
+
+import org.jspecify.annotations.Nullable;
+
+/**
+ * The framework-agnostic request carrier threaded through the fixed pipeline. The edge builds
+ * one instance per inbound request from its framework-specific {@code RoutingContext} (no
+ * Vert.x / Quarkus type leaks in here), then hands it to stages 0-7 in order.
+ *
+ * Inbound fields are immutable (the raw method, path, query, headers, host,
+ * peer address, and body framing, all supplied by the edge). Derived fields are
+ * populated by the stages as the request flows: the {@linkplain #canonicalPath()
+ * single canonical path} (stage 1), the {@linkplain #selectedRoute() selected route} (stage 2),
+ * the accumulating {@linkplain #responseHeaders() response-header map} (stage 0 onward, applied
+ * to every response including rejections), and an optional {@linkplain #shortCircuitStatus()
+ * short-circuit status} (e.g. a CORS preflight answered at stage 0 before auth).
+ *
+ * Header lookups are case-insensitive: header names are normalized to lower case when the
+ * instance is built, matching RFC 7230 field-name semantics.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+public final class PipelineRequest {
+
+ private final HttpMethod method;
+ private final String requestPath;
+ private final Map> queryParameters;
+ private final Map> headers;
+ private final @Nullable String host;
+ private final @Nullable String peerAddress;
+ private final long declaredContentLength;
+ private final boolean bodyPresent;
+
+ private final Map responseHeaders = new LinkedHashMap<>();
+ private @Nullable String canonicalPath;
+ private @Nullable RouteRuntime selectedRoute;
+ private @Nullable Integer shortCircuitStatus;
+
+ private PipelineRequest(Builder builder) {
+ this.method = Objects.requireNonNull(builder.method, "method");
+ this.requestPath = Objects.requireNonNull(builder.requestPath, "requestPath");
+ this.queryParameters = Map.copyOf(builder.queryParameters);
+ this.headers = normalizeHeaders(builder.headers);
+ this.host = builder.host;
+ this.peerAddress = builder.peerAddress;
+ this.declaredContentLength = builder.declaredContentLength;
+ this.bodyPresent = builder.bodyPresent;
+ }
+
+ private static Map> normalizeHeaders(Map> raw) {
+ Map> normalized = new LinkedHashMap<>();
+ raw.forEach((name, values) -> normalized.merge(
+ name.toLowerCase(Locale.ROOT), List.copyOf(values),
+ (existing, added) -> {
+ List merged = new ArrayList<>(existing);
+ merged.addAll(added);
+ return List.copyOf(merged);
+ }));
+ return Map.copyOf(normalized);
+ }
+
+ /**
+ * @return a new builder for a {@link PipelineRequest}
+ */
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ /**
+ * @return the inbound request method
+ */
+ public HttpMethod method() {
+ return method;
+ }
+
+ /**
+ * @return the raw inbound request path, exactly as received (before canonicalization)
+ */
+ public String requestPath() {
+ return requestPath;
+ }
+
+ /**
+ * @return the inbound query parameters keyed by name (values in inbound order), empty when none
+ */
+ public Map> queryParameters() {
+ return queryParameters;
+ }
+
+ /**
+ * @return the inbound headers, lower-case-keyed, values in inbound order, empty when none
+ */
+ public Map> headers() {
+ return headers;
+ }
+
+ /**
+ * @param name the header name (case-insensitive)
+ * @return the first value for {@code name}, or empty when the header is absent
+ */
+ public Optional firstHeader(String name) {
+ List values = headers.get(name.toLowerCase(Locale.ROOT));
+ return values == null || values.isEmpty() ? Optional.empty() : Optional.of(values.getFirst());
+ }
+
+ /**
+ * @param name the header name (case-insensitive)
+ * @return every value for {@code name}, empty when the header is absent
+ */
+ public List headerValues(String name) {
+ return headers.getOrDefault(name.toLowerCase(Locale.ROOT), List.of());
+ }
+
+ /**
+ * @param name the header name (case-insensitive)
+ * @return {@code true} when the header is present with at least one value
+ */
+ public boolean hasHeader(String name) {
+ List values = headers.get(name.toLowerCase(Locale.ROOT));
+ return values != null && !values.isEmpty();
+ }
+
+ /**
+ * A single-valued view of the headers (first value per name) for the compiled
+ * {@link RouteRuntime#matcher() route matcher}, which matches on scalar header values.
+ *
+ * @return the first value per header name, lower-case-keyed
+ */
+ public Map singleValueHeaders() {
+ Map single = new LinkedHashMap<>();
+ headers.forEach((name, values) -> {
+ if (!values.isEmpty()) {
+ single.put(name, values.getFirst());
+ }
+ });
+ return Map.copyOf(single);
+ }
+
+ /**
+ * @return the request host authority, or {@code null} when absent
+ */
+ public @Nullable String host() {
+ return host;
+ }
+
+ /**
+ * @return the immediate TCP peer address, or {@code null} when the edge did not supply one
+ */
+ public @Nullable String peerAddress() {
+ return peerAddress;
+ }
+
+ /**
+ * @return the declared {@code Content-Length} in bytes, or {@code -1} when none was declared
+ */
+ public long declaredContentLength() {
+ return declaredContentLength;
+ }
+
+ /**
+ * @return {@code true} when the inbound request carries (or announces) a body
+ */
+ public boolean bodyPresent() {
+ return bodyPresent;
+ }
+
+ /**
+ * @return the single canonical path resolved by stage 1, or {@code null} before stage 1 runs
+ */
+ public @Nullable String canonicalPath() {
+ return canonicalPath;
+ }
+
+ /**
+ * Records the single canonical path every later stage consumes (GW-01 single-path invariant).
+ *
+ * @param canonicalPath the validated, normalized path
+ */
+ public void canonicalPath(String canonicalPath) {
+ this.canonicalPath = Objects.requireNonNull(canonicalPath, "canonicalPath");
+ }
+
+ /**
+ * @return the route selected by stage 2, or {@code null} before selection
+ */
+ public @Nullable RouteRuntime selectedRoute() {
+ return selectedRoute;
+ }
+
+ /**
+ * Records the route selected by stage 2.
+ *
+ * @param selectedRoute the matched route runtime
+ */
+ public void selectedRoute(RouteRuntime selectedRoute) {
+ this.selectedRoute = Objects.requireNonNull(selectedRoute, "selectedRoute");
+ }
+
+ /**
+ * The mutable response-header map stage 0 seeds and later stages append to; the edge applies
+ * it to every response, including rejections.
+ *
+ * @return the accumulating response headers
+ */
+ public Map responseHeaders() {
+ return responseHeaders;
+ }
+
+ /**
+ * @return the short-circuit status the edge must return immediately (e.g. a CORS preflight
+ * answered at stage 0), or empty when the request should flow through the full pipeline
+ */
+ public Optional shortCircuitStatus() {
+ return Optional.ofNullable(shortCircuitStatus);
+ }
+
+ /**
+ * Marks the request as answered before reaching the upstream — the edge returns {@code status}
+ * with the accumulated {@link #responseHeaders()} and no upstream call.
+ *
+ * @param status the HTTP status to return
+ */
+ public void shortCircuit(int status) {
+ this.shortCircuitStatus = status;
+ }
+
+ /**
+ * Builder collecting the immutable inbound components of a {@link PipelineRequest}.
+ */
+ public static final class Builder {
+
+ private @Nullable HttpMethod method;
+ private @Nullable String requestPath;
+ private Map> queryParameters = Map.of();
+ private Map> headers = Map.of();
+ private @Nullable String host;
+ private @Nullable String peerAddress;
+ private long declaredContentLength = -1L;
+ private boolean bodyPresent;
+
+ private Builder() {
+ }
+
+ /**
+ * @param method the inbound method
+ * @return this builder
+ */
+ public Builder method(HttpMethod method) {
+ this.method = method;
+ return this;
+ }
+
+ /**
+ * @param requestPath the raw inbound path
+ * @return this builder
+ */
+ public Builder requestPath(String requestPath) {
+ this.requestPath = requestPath;
+ return this;
+ }
+
+ /**
+ * @param queryParameters the inbound query parameters, keyed by name
+ * @return this builder
+ */
+ public Builder queryParameters(Map> queryParameters) {
+ this.queryParameters = queryParameters;
+ return this;
+ }
+
+ /**
+ * @param headers the inbound headers, keyed by name (normalized to lower case at build)
+ * @return this builder
+ */
+ public Builder headers(Map> headers) {
+ this.headers = headers;
+ return this;
+ }
+
+ /**
+ * @param host the request host authority
+ * @return this builder
+ */
+ public Builder host(@Nullable String host) {
+ this.host = host;
+ return this;
+ }
+
+ /**
+ * @param peerAddress the immediate TCP peer address
+ * @return this builder
+ */
+ public Builder peerAddress(@Nullable String peerAddress) {
+ this.peerAddress = peerAddress;
+ return this;
+ }
+
+ /**
+ * @param declaredContentLength the declared {@code Content-Length}, or {@code -1} when absent
+ * @return this builder
+ */
+ public Builder declaredContentLength(long declaredContentLength) {
+ this.declaredContentLength = declaredContentLength;
+ return this;
+ }
+
+ /**
+ * @param bodyPresent whether the request carries or announces a body
+ * @return this builder
+ */
+ public Builder bodyPresent(boolean bodyPresent) {
+ this.bodyPresent = bodyPresent;
+ return this;
+ }
+
+ /**
+ * @return the assembled immutable-inbound {@link PipelineRequest}
+ */
+ public PipelineRequest build() {
+ return new PipelineRequest(this);
+ }
+ }
+}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/pipeline/RouteSelectionStage.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/pipeline/RouteSelectionStage.java
new file mode 100644
index 0000000..fa4ec3e
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/pipeline/RouteSelectionStage.java
@@ -0,0 +1,81 @@
+/*
+ * 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.pipeline;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+
+import de.cuioss.sheriff.api.events.EventType;
+import de.cuioss.sheriff.api.events.GatewayException;
+import de.cuioss.sheriff.api.routing.RouteRuntime;
+
+/**
+ * Stage 2 — deny-by-default route selection on the single canonical path.
+ *
+ * The stage walks the boot-assembled {@link RouteRuntime} list (already ordered longest-prefix-first
+ * by the {@code RouteRuntimeAssembler}) and selects the first route whose compiled
+ * {@link RouteRuntime#matcher() matcher} accepts the request — prefix and match-methods
+ * and host and every header matcher. Because the list is longest-prefix-first, the
+ * first match is the most specific route. No candidate is a hard 404
+ * ({@link EventType#NO_ROUTE_MATCHED}); the gateway never forwards an unmatched request.
+ *
+ * Selection consumes the {@link PipelineRequest#canonicalPath() canonical path} set at stage 1
+ * (GW-01 single-path invariant), never the raw inbound path.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+public final class RouteSelectionStage {
+
+ private final List routes;
+
+ /**
+ * @param routes the boot-assembled routes, ordered longest {@code path_prefix} first
+ */
+ public RouteSelectionStage(List routes) {
+ this.routes = List.copyOf(Objects.requireNonNull(routes, "routes"));
+ }
+
+ /**
+ * Selects the most specific matching route and records it on the request.
+ *
+ * @param request the in-flight request context; its canonical path must be set (stage 1)
+ * @throws GatewayException with {@link EventType#NO_ROUTE_MATCHED} when no route matches
+ */
+ public void process(PipelineRequest request) {
+ Objects.requireNonNull(request, "request");
+ String canonicalPath = requireCanonicalPath(request);
+ Map headers = request.singleValueHeaders();
+ for (RouteRuntime route : routes) {
+ if (route.getMatcher().matches(canonicalPath, request.method(), request.host(), headers)) {
+ request.selectedRoute(route);
+ return;
+ }
+ }
+ throw new GatewayException(EventType.NO_ROUTE_MATCHED,
+ "No route matched canonical path (method " + request.method() + ")");
+ }
+
+ private static String requireCanonicalPath(PipelineRequest request) {
+ String canonicalPath = request.canonicalPath();
+ if (canonicalPath == null) {
+ throw new IllegalStateException("Route selection requires the canonical path resolved at stage 1");
+ }
+ return canonicalPath;
+ }
+}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/pipeline/SecurityHeadersStage.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/pipeline/SecurityHeadersStage.java
new file mode 100644
index 0000000..ecbd076
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/pipeline/SecurityHeadersStage.java
@@ -0,0 +1,120 @@
+/*
+ * 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.pipeline;
+
+import java.util.Objects;
+import java.util.Optional;
+
+
+import de.cuioss.sheriff.api.config.model.HttpMethod;
+import de.cuioss.sheriff.api.config.model.SecurityHeadersConfig;
+import de.cuioss.sheriff.api.config.model.SecurityHeadersConfig.Cors;
+
+/**
+ * Stage 0 — response-header preparation and CORS preflight, run before authentication.
+ *
+ * The stage seeds the {@link PipelineRequest#responseHeaders() response-header map} the edge
+ * applies to every response (success and rejection alike): {@code Strict-Transport-Security},
+ * {@code X-Content-Type-Options: nosniff}, and {@code X-Frame-Options: DENY}, each emitted only when
+ * the global {@code security_headers} block enables it. When CORS is enabled and the inbound request
+ * is a preflight ({@code OPTIONS} carrying {@code Origin} and {@code Access-Control-Request-Method}
+ * from an allow-listed origin), the stage answers it here — {@linkplain PipelineRequest#shortCircuit(int)
+ * short-circuiting} with {@code 204} and the CORS response headers — so a browser preflight never
+ * reaches authentication or the upstream.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+public final class SecurityHeadersStage {
+
+ private static final int NO_CONTENT = 204;
+ private static final String WILDCARD_ORIGIN = "*";
+
+ private final Optional config;
+
+ /**
+ * @param config the global {@code security_headers} posture, empty when none is configured
+ */
+ public SecurityHeadersStage(Optional config) {
+ this.config = Objects.requireNonNull(config, "config");
+ }
+
+ /**
+ * Seeds response headers and, for an allow-listed CORS preflight, short-circuits the request.
+ *
+ * @param request the in-flight request context
+ */
+ public void process(PipelineRequest request) {
+ Objects.requireNonNull(request, "request");
+ config.ifPresent(headers -> applyResponseHeaders(request, headers));
+ config.flatMap(SecurityHeadersConfig::cors)
+ .filter(cors -> cors.enabled().orElse(Boolean.FALSE))
+ .ifPresent(cors -> applyCors(request, cors));
+ }
+
+ private static void applyResponseHeaders(PipelineRequest request, SecurityHeadersConfig headers) {
+ headers.hsts().ifPresent(hsts -> {
+ StringBuilder value = new StringBuilder("max-age=").append(hsts.maxAge().orElse(0));
+ if (hsts.includeSubdomains().orElse(Boolean.FALSE)) {
+ value.append("; includeSubDomains");
+ }
+ request.responseHeaders().put("Strict-Transport-Security", value.toString());
+ });
+ if (headers.contentTypeNosniff().orElse(Boolean.FALSE)) {
+ request.responseHeaders().put("X-Content-Type-Options", "nosniff");
+ }
+ if (headers.frameDeny().orElse(Boolean.FALSE)) {
+ request.responseHeaders().put("X-Frame-Options", "DENY");
+ }
+ }
+
+ private static void applyCors(PipelineRequest request, Cors cors) {
+ Optional origin = request.firstHeader("Origin");
+ if (origin.isEmpty() || !isOriginAllowed(cors, origin.get())) {
+ return;
+ }
+ request.responseHeaders().put("Access-Control-Allow-Origin", origin.get());
+ if (cors.allowCredentials().orElse(Boolean.FALSE)) {
+ request.responseHeaders().put("Access-Control-Allow-Credentials", "true");
+ }
+ if (isPreflight(request)) {
+ if (!cors.allowedMethods().isEmpty()) {
+ request.responseHeaders().put("Access-Control-Allow-Methods", String.join(", ", cors.allowedMethods()));
+ }
+ if (!cors.allowedHeaders().isEmpty()) {
+ request.responseHeaders().put("Access-Control-Allow-Headers", String.join(", ", cors.allowedHeaders()));
+ }
+ request.shortCircuit(NO_CONTENT);
+ }
+ }
+
+ /**
+ * Decides whether {@code origin} is CORS-allowed. A configured {@code "*"} is a real wildcard
+ * (ConfigValidator permits it only when {@code allowCredentials} is false): a request {@code Origin}
+ * header is never literally {@code "*"}, so without this wildcard branch a configured wildcard would
+ * silently never match and CORS headers would never be emitted for any origin. When the wildcard is
+ * present the presented origin is accepted and reflected by the caller; otherwise the origin must be
+ * listed explicitly.
+ */
+ private static boolean isOriginAllowed(Cors cors, String origin) {
+ return cors.allowedOrigins().contains(WILDCARD_ORIGIN) || cors.allowedOrigins().contains(origin);
+ }
+
+ private static boolean isPreflight(PipelineRequest request) {
+ return request.method() == HttpMethod.OPTIONS
+ && request.hasHeader("Access-Control-Request-Method");
+ }
+}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/pipeline/ThoroughChecksStage.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/pipeline/ThoroughChecksStage.java
new file mode 100644
index 0000000..27a5dc7
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/pipeline/ThoroughChecksStage.java
@@ -0,0 +1,175 @@
+/*
+ * 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.pipeline;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.concurrent.ConcurrentHashMap;
+
+
+import de.cuioss.http.security.config.SecurityConfiguration;
+import de.cuioss.http.security.exceptions.UrlSecurityException;
+import de.cuioss.http.security.monitoring.SecurityEventCounter;
+import de.cuioss.http.security.pipeline.PipelineFactory;
+import de.cuioss.sheriff.api.events.EventType;
+import de.cuioss.sheriff.api.events.GatewayException;
+import de.cuioss.sheriff.api.routing.RouteRuntime;
+
+/**
+ * Stage 3 — per-route thorough checks, run after the verb gate on the selected route.
+ *
+ * Three per-route enforcements, all on the single canonical path:
+ *
+ * - Re-run only on divergence. When the route carries its own
+ * {@link SecurityConfiguration} that differs from the stage-1 default, its pipelines are
+ * re-run on the canonical path, every parameter value, and every header value; a route whose
+ * config equals the default is skipped (stage 1 already covered it). Per-config pipeline sets
+ * are cached so shared route shapes reuse one set.
+ * - {@code allowed_paths} whitelist. When the caller supplies a non-empty
+ * whitelist, the canonical path must match one pattern, where a {@code {name}} segment matches
+ * exactly one path segment; a miss is a 400 {@link EventType#PATH_NOT_ALLOWED}.
+ * - {@code max_body_bytes} fast-reject. A declared {@code Content-Length}
+ * already exceeding the route config's {@code maxBodySize} is rejected 400
+ * ({@link EventType#PARAMETER_LIMIT_EXCEEDED}) before the body is read.
+ *
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+public final class ThoroughChecksStage {
+
+ private static final String SEGMENT_WILDCARD_PREFIX = "{";
+ private static final String SEGMENT_WILDCARD_SUFFIX = "}";
+
+ private final SecurityConfiguration defaultConfiguration;
+ private final SecurityEventCounter eventCounter;
+ private final Map pipelineCache = new ConcurrentHashMap<>();
+
+ /**
+ * @param defaultConfiguration the stage-1 default policy, used to skip a route whose config matches
+ * @param eventCounter the shared cui-http security event counter (never a local instance)
+ */
+ public ThoroughChecksStage(SecurityConfiguration defaultConfiguration, SecurityEventCounter eventCounter) {
+ this.defaultConfiguration = Objects.requireNonNull(defaultConfiguration, "defaultConfiguration");
+ this.eventCounter = Objects.requireNonNull(eventCounter, "eventCounter");
+ }
+
+ /**
+ * Runs the per-route thorough checks.
+ *
+ * @param request the in-flight request context; its route must be selected (stage 2)
+ * @param allowedPaths the selected route's {@code allowed_paths} whitelist, empty when unrestricted
+ * @throws GatewayException on a divergent-pipeline violation ({@link EventType#SECURITY_FILTER_VIOLATION}),
+ * a whitelist miss ({@link EventType#PATH_NOT_ALLOWED}), or a body-cap breach
+ * ({@link EventType#PARAMETER_LIMIT_EXCEEDED})
+ */
+ public void process(PipelineRequest request, List allowedPaths) {
+ Objects.requireNonNull(request, "request");
+ Objects.requireNonNull(allowedPaths, "allowedPaths");
+ RouteRuntime route = requireSelectedRoute(request);
+ String canonicalPath = requireCanonicalPath(request);
+
+ route.getSecurityConfiguration().ifPresent(routeConfig -> {
+ if (!routeConfig.equals(defaultConfiguration)) {
+ reRunPipelines(request, routeConfig, canonicalPath);
+ }
+ enforceBodyCap(request, routeConfig);
+ });
+ enforceAllowedPaths(canonicalPath, allowedPaths);
+ }
+
+ private void reRunPipelines(PipelineRequest request, SecurityConfiguration routeConfig, String canonicalPath) {
+ PipelineFactory.PipelineSet pipelines = pipelineCache.computeIfAbsent(routeConfig,
+ config -> PipelineFactory.createCommonPipelines(config, eventCounter));
+ try {
+ pipelines.urlPathPipeline().validate(canonicalPath);
+ for (List values : request.queryParameters().values()) {
+ for (String value : values) {
+ pipelines.urlParameterPipeline().validate(value);
+ }
+ }
+ for (List values : request.headers().values()) {
+ for (String value : values) {
+ pipelines.headerValuePipeline().validate(value);
+ }
+ }
+ } catch (UrlSecurityException violation) {
+ throw new GatewayException(EventType.SECURITY_FILTER_VIOLATION,
+ "Per-route filter rejected %s at %s".formatted(violation.getFailureType(),
+ violation.getValidationType()),
+ violation);
+ }
+ }
+
+ private static void enforceBodyCap(PipelineRequest request, SecurityConfiguration routeConfig) {
+ long cap = routeConfig.maxBodySize();
+ if (request.declaredContentLength() > cap) {
+ throw new GatewayException(EventType.PARAMETER_LIMIT_EXCEEDED,
+ "Declared body %d exceeds route cap %d".formatted(request.declaredContentLength(), cap));
+ }
+ }
+
+ private static void enforceAllowedPaths(String canonicalPath, List allowedPaths) {
+ if (allowedPaths.isEmpty()) {
+ return;
+ }
+ for (String pattern : allowedPaths) {
+ if (matchesPattern(canonicalPath, pattern)) {
+ return;
+ }
+ }
+ throw new GatewayException(EventType.PATH_NOT_ALLOWED, "Canonical path outside route allowed_paths");
+ }
+
+ private static boolean matchesPattern(String path, String pattern) {
+ String[] pathSegments = path.split("/", -1);
+ String[] patternSegments = pattern.split("/", -1);
+ if (pathSegments.length != patternSegments.length) {
+ return false;
+ }
+ for (int i = 0; i < patternSegments.length; i++) {
+ String patternSegment = patternSegments[i];
+ if (isWildcard(patternSegment)) {
+ if (pathSegments[i].isEmpty()) {
+ return false;
+ }
+ } else if (!patternSegment.equals(pathSegments[i])) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private static boolean isWildcard(String segment) {
+ return segment.startsWith(SEGMENT_WILDCARD_PREFIX) && segment.endsWith(SEGMENT_WILDCARD_SUFFIX);
+ }
+
+ private static RouteRuntime requireSelectedRoute(PipelineRequest request) {
+ RouteRuntime route = request.selectedRoute();
+ if (route == null) {
+ throw new IllegalStateException("Thorough checks require the route selected at stage 2");
+ }
+ return route;
+ }
+
+ private static String requireCanonicalPath(PipelineRequest request) {
+ Optional canonicalPath = Optional.ofNullable(request.canonicalPath());
+ return canonicalPath.orElseThrow(
+ () -> new IllegalStateException("Thorough checks require the canonical path resolved at stage 1"));
+ }
+}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/pipeline/VerbGateStage.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/pipeline/VerbGateStage.java
new file mode 100644
index 0000000..680faaa
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/pipeline/VerbGateStage.java
@@ -0,0 +1,70 @@
+/*
+ * 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.pipeline;
+
+import java.util.Objects;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+
+import de.cuioss.sheriff.api.config.model.HttpMethod;
+import de.cuioss.sheriff.api.events.EventType;
+import de.cuioss.sheriff.api.events.GatewayException;
+import de.cuioss.sheriff.api.routing.RouteRuntime;
+
+/**
+ * Stage 2b — the per-route verb gate, run after route selection.
+ *
+ * A request whose method is outside the selected route's effective {@code allowed_methods} is
+ * rejected 405 ({@link EventType#METHOD_NOT_ALLOWED}) with the {@code Allow} response header naming
+ * the permitted verbs, per RFC 7231 §6.5.5. The {@code Allow} header is seeded onto the request's
+ * response-header map before the rejection is thrown so the edge renders it on the 405. The upstream
+ * is never contacted for a rejected verb.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+public final class VerbGateStage {
+
+ /**
+ * Enforces the route's effective verb allowlist.
+ *
+ * @param request the in-flight request context; its route must be selected (stage 2)
+ * @throws GatewayException with {@link EventType#METHOD_NOT_ALLOWED} when the verb is not allowed
+ */
+ public void process(PipelineRequest request) {
+ Objects.requireNonNull(request, "request");
+ RouteRuntime route = requireSelectedRoute(request);
+ Set allowed = route.getEffectiveAllowedMethods();
+ if (!allowed.contains(request.method())) {
+ request.responseHeaders().put("Allow", renderAllow(allowed));
+ throw new GatewayException(EventType.METHOD_NOT_ALLOWED,
+ "Method " + request.method() + " not allowed for route " + route.getId());
+ }
+ }
+
+ private static String renderAllow(Set allowed) {
+ return allowed.stream().sorted().map(Enum::name).collect(Collectors.joining(", "));
+ }
+
+ private static RouteRuntime requireSelectedRoute(PipelineRequest request) {
+ RouteRuntime route = request.selectedRoute();
+ if (route == null) {
+ throw new IllegalStateException("Verb gate requires the route selected at stage 2");
+ }
+ return route;
+ }
+}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/pipeline/package-info.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/pipeline/package-info.java
new file mode 100644
index 0000000..562deee
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/pipeline/package-info.java
@@ -0,0 +1,48 @@
+/*
+ * 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.
+ */
+/**
+ * The framework-agnostic request pipeline: stages 0-3 of the fixed inbound flow plus the D3b
+ * GW-01 / GW-02 hardening gates.
+ *
+ * Every stage operates on the agnostic {@link de.cuioss.sheriff.api.pipeline.PipelineRequest}
+ * carrier the edge builds from its framework-specific request, so this package carries no
+ * {@code io.vertx..} / {@code io.quarkus..} / {@code jakarta..} / {@code org.eclipse.microprofile..}
+ * imports and stays inside the ADR-0005 framework-agnostic arch-gate rule set. Stages reject by
+ * throwing a typed {@link de.cuioss.sheriff.api.events.GatewayException}; the framework edge maps it
+ * to the RFC 9457 response and never lets a rejected request reach the upstream.
+ *
+ * - {@link de.cuioss.sheriff.api.pipeline.SecurityHeadersStage} — stage 0: response-header
+ * preparation and CORS preflight, before auth;
+ * - {@link de.cuioss.sheriff.api.pipeline.BasicChecksStage} — stage 1: the baseline cui-http
+ * filter yielding the single canonical path, plus collection-limit fast-reject;
+ * - {@link de.cuioss.sheriff.api.pipeline.FramingGate} — D3b GW-02 anti-smuggling framing gate;
+ * - {@link de.cuioss.sheriff.api.pipeline.CanonicalPathGuard} — D3b GW-01 single-canonical-path
+ * guard (encoded separator / matrix parameter rejection);
+ * - {@link de.cuioss.sheriff.api.pipeline.RouteSelectionStage} — stage 2: deny-by-default
+ * longest-prefix route selection;
+ * - {@link de.cuioss.sheriff.api.pipeline.VerbGateStage} — stage 2b: the 405 verb gate with
+ * {@code Allow};
+ * - {@link de.cuioss.sheriff.api.pipeline.ThoroughChecksStage} — stage 3: per-route divergent
+ * filters, {@code allowed_paths} whitelist, and {@code max_body_bytes} fast-reject.
+ *
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+@NullMarked
+package de.cuioss.sheriff.api.pipeline;
+
+import org.jspecify.annotations.NullMarked;
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/quarkus/ApiSheriffProducer.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/quarkus/ApiSheriffProducer.java
deleted file mode 100644
index 741c3d6..0000000
--- a/api-sheriff/src/main/java/de/cuioss/sheriff/api/quarkus/ApiSheriffProducer.java
+++ /dev/null
@@ -1,47 +0,0 @@
-/*
- * 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.quarkus;
-
-import de.cuioss.sheriff.api.ApiSheriff;
-import de.cuioss.tools.logging.CuiLogger;
-import io.quarkus.runtime.annotations.RegisterForReflection;
-import jakarta.enterprise.context.ApplicationScoped;
-import jakarta.enterprise.inject.Produces;
-
-/**
- * CDI producer for API Sheriff components.
- * This class provides CDI integration for the API Sheriff library in Quarkus applications.
- *
- * @author API Sheriff Team
- */
-@ApplicationScoped
-@RegisterForReflection
-public class ApiSheriffProducer {
-
- private static final CuiLogger LOGGER = new CuiLogger(ApiSheriffProducer.class);
-
- /**
- * Produces an application-scoped ApiSheriff instance.
- *
- * @return configured ApiSheriff instance
- */
- @Produces
- @ApplicationScoped
- public ApiSheriff produceApiSheriff() {
- LOGGER.debug("Creating ApiSheriff instance");
- return new ApiSheriff();
- }
-}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/quarkus/ConfigModelReflection.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/quarkus/ConfigModelReflection.java
index 60d7b14..c4c5009 100644
--- a/api-sheriff/src/main/java/de/cuioss/sheriff/api/quarkus/ConfigModelReflection.java
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/quarkus/ConfigModelReflection.java
@@ -36,6 +36,7 @@
import de.cuioss.sheriff.api.config.model.TokenValidationConfig;
import de.cuioss.sheriff.api.config.model.UpstreamConfig;
import de.cuioss.sheriff.api.config.model.UpstreamDefaultsConfig;
+
import io.quarkus.runtime.annotations.RegisterForReflection;
/**
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/quarkus/ConfigProducer.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/quarkus/ConfigProducer.java
index 0349b9d..30d0b41 100644
--- a/api-sheriff/src/main/java/de/cuioss/sheriff/api/quarkus/ConfigProducer.java
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/quarkus/ConfigProducer.java
@@ -19,6 +19,7 @@
import java.util.List;
import java.util.Map;
+
import de.cuioss.sheriff.api.config.ConfigLogMessages;
import de.cuioss.sheriff.api.config.RouteTableBuilder;
import de.cuioss.sheriff.api.config.load.ConfigError;
@@ -34,6 +35,7 @@
import de.cuioss.sheriff.api.config.topology.TopologyResolver;
import de.cuioss.sheriff.api.config.validation.ConfigValidator;
import de.cuioss.tools.logging.CuiLogger;
+
import io.quarkus.runtime.StartupEvent;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.event.Observes;
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/quarkus/GatewayExceptionMapper.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/quarkus/GatewayExceptionMapper.java
new file mode 100644
index 0000000..c7cca89
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/quarkus/GatewayExceptionMapper.java
@@ -0,0 +1,85 @@
+/*
+ * 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.quarkus;
+
+import de.cuioss.sheriff.api.events.EventCategory;
+import de.cuioss.sheriff.api.events.EventType;
+import de.cuioss.sheriff.api.events.GatewayException;
+import de.cuioss.sheriff.token.commons.events.SecurityEventCounter;
+import de.cuioss.sheriff.token.validation.exception.TokenValidationException;
+
+import jakarta.ws.rs.core.Response;
+import jakarta.ws.rs.ext.ExceptionMapper;
+import jakarta.ws.rs.ext.Provider;
+
+/**
+ * Framework-edge handler that renders a {@link GatewayException} as an RFC 9457
+ * {@code application/problem+json} response. The problem {@code type} is named by the
+ * failing event's {@link EventCategory}; the {@code status} is the event's HTTP status;
+ * no internal detail is leaked into the body.
+ *
+ * {@link TokenValidationException} thrown by the token-sheriff validator is normalized to
+ * the gateway's {@link EventType#TOKEN_MISSING} / {@link EventType#TOKEN_INVALID}
+ * equivalents via {@link #translate(TokenValidationException)}, so bearer-token failures
+ * render through the same contract.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+@Provider
+public class GatewayExceptionMapper implements ExceptionMapper {
+
+ private static final String PROBLEM_JSON = "application/problem+json";
+ private static final int INTERNAL_ERROR = 500;
+
+ @Override
+ public Response toResponse(GatewayException exception) {
+ return render(exception.getEventType());
+ }
+
+ /**
+ * Renders the RFC 9457 problem+json response for the given event type.
+ *
+ * @param eventType the failure event
+ * @return a problem+json {@link Response} carrying {@code type}, {@code title}, and {@code status}
+ */
+ static Response render(EventType eventType) {
+ int status = eventType.hasHttpMapping() ? eventType.httpStatus() : INTERNAL_ERROR;
+ EventCategory category = eventType.category();
+ String type = category != null ? category.problemType() : "about:blank";
+ String title = category != null ? category.title() : "Internal Server Error";
+ String body = "{\"type\":\"" + type + "\",\"title\":\"" + title + "\",\"status\":" + status + "}";
+ return Response.status(status)
+ .type(PROBLEM_JSON)
+ .entity(body)
+ .build();
+ }
+
+ /**
+ * Normalizes a token-validation failure into the gateway's authentication event. An
+ * empty / missing token maps to {@link EventType#TOKEN_MISSING}; every other validation
+ * failure maps to {@link EventType#TOKEN_INVALID}.
+ *
+ * @param exception the token-sheriff validation failure
+ * @return an equivalent {@link GatewayException} carrying the mapped event type
+ */
+ static GatewayException translate(TokenValidationException exception) {
+ EventType mapped = exception.getEventType() == SecurityEventCounter.EventType.TOKEN_EMPTY
+ ? EventType.TOKEN_MISSING
+ : EventType.TOKEN_INVALID;
+ return new GatewayException(mapped, mapped.name(), exception);
+ }
+}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/quarkus/GatewayReadinessCheck.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/quarkus/GatewayReadinessCheck.java
new file mode 100644
index 0000000..f46ac29
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/quarkus/GatewayReadinessCheck.java
@@ -0,0 +1,114 @@
+/*
+ * 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.quarkus;
+
+import java.util.Optional;
+
+
+import de.cuioss.sheriff.api.auth.GatewayValidator;
+import de.cuioss.sheriff.api.config.model.GatewayConfig;
+import de.cuioss.sheriff.api.config.model.Metadata;
+import de.cuioss.sheriff.api.config.model.TokenValidationConfig;
+import de.cuioss.sheriff.api.events.GatewayException;
+import de.cuioss.sheriff.token.validation.TokenValidator;
+
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.enterprise.inject.CreationException;
+import jakarta.enterprise.inject.Instance;
+import jakarta.inject.Inject;
+import org.eclipse.microprofile.health.HealthCheck;
+import org.eclipse.microprofile.health.HealthCheckResponse;
+import org.eclipse.microprofile.health.HealthCheckResponseBuilder;
+import org.eclipse.microprofile.health.Readiness;
+
+/**
+ * SmallRye {@code @Readiness} probe served on the management port ({@code /q/health/ready}),
+ * reporting whether the gateway is ready to serve traffic.
+ *
+ * Readiness reflects two facts, per {@code architecture.adoc} § Metrics (Health):
+ *
+ * - Configuration — the {@link GatewayConfig} bean is present, which proves
+ * the boot-time load-and-validate pipeline in {@link ConfigProducer} succeeded (an invalid
+ * configuration aborts startup, so the application would never reach readiness with an
+ * unbound config);
+ * - JWKS — when a {@code token_validation} block is configured, the gateway's
+ * own {@link GatewayValidator}-qualified {@link TokenValidator} resolves successfully. Building
+ * that validator requires every configured issuer to declare a usable JWKS source, so a
+ * resolution failure ({@link GatewayException}) marks the probe {@code DOWN} with the cause.
+ * A gateway with no {@code token_validation} block needs no bearer validation, so JWKS is
+ * reported {@code not-applicable} and does not gate readiness.
+ *
+ * The validator is resolved lazily through an {@link Instance} so a misconfigured JWKS source
+ * yields a clean {@code DOWN} response rather than failing this probe's own construction.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+@Readiness
+@ApplicationScoped
+public class GatewayReadinessCheck implements HealthCheck {
+
+ private static final String CHECK_NAME = "gateway-readiness";
+ private static final String DATA_CONFIG = "config";
+ private static final String DATA_CONFIG_VERSION = "config_version";
+ private static final String DATA_JWKS = "jwks";
+ private static final String DATA_ISSUERS = "issuers";
+ private static final String DATA_ERROR = "error";
+
+ private final GatewayConfig gatewayConfig;
+ private final Instance gatewayValidator;
+
+ /**
+ * @param gatewayConfig the bound, boot-validated gateway document
+ * @param gatewayValidator the lazily-resolved gateway bearer-token validator
+ */
+ @Inject
+ public GatewayReadinessCheck(GatewayConfig gatewayConfig,
+ @GatewayValidator Instance gatewayValidator) {
+ this.gatewayConfig = gatewayConfig;
+ this.gatewayValidator = gatewayValidator;
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * @return {@code UP} when the configuration is bound and — if bearer validation is configured —
+ * the JWKS-backed validator resolves; {@code DOWN} carrying the failure cause otherwise
+ */
+ @Override
+ public HealthCheckResponse call() {
+ HealthCheckResponseBuilder builder = HealthCheckResponse.named(CHECK_NAME)
+ .withData(DATA_CONFIG, "loaded");
+ gatewayConfig.metadata().flatMap(Metadata::configVersion)
+ .ifPresent(version -> builder.withData(DATA_CONFIG_VERSION, version));
+
+ Optional tokenValidation = gatewayConfig.tokenValidation();
+ if (tokenValidation.isEmpty()) {
+ return builder.withData(DATA_JWKS, "not-applicable").up().build();
+ }
+
+ int issuerCount = tokenValidation.get().issuers().size();
+ builder.withData(DATA_ISSUERS, issuerCount);
+ try {
+ gatewayValidator.get();
+ return builder.withData(DATA_JWKS, "ready").up().build();
+ } catch (GatewayException | CreationException failure) {
+ return builder.withData(DATA_JWKS, "unavailable")
+ .withData(DATA_ERROR, String.valueOf(failure.getMessage()))
+ .down().build();
+ }
+ }
+}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/quarkus/SheriffMetrics.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/quarkus/SheriffMetrics.java
new file mode 100644
index 0000000..ba2850f
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/quarkus/SheriffMetrics.java
@@ -0,0 +1,169 @@
+/*
+ * 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.quarkus;
+
+import java.time.Duration;
+import java.util.Objects;
+
+
+import de.cuioss.http.security.core.UrlSecurityFailureType;
+import de.cuioss.http.security.monitoring.SecurityEventCounter;
+import de.cuioss.sheriff.api.events.EventCategory;
+
+import io.micrometer.core.instrument.FunctionCounter;
+import io.micrometer.core.instrument.MeterRegistry;
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.inject.Inject;
+
+/**
+ * The framework-bound metrics adapter (ADR-0005 seam) that surfaces the gateway's request,
+ * error, security, and upstream signals as Micrometer meters on the management port, exposed
+ * as Prometheus data via {@code quarkus-micrometer-registry-prometheus}.
+ *
+ * The meter names are the fixed contract named in {@code architecture.adoc} § Metrics:
+ *
+ * - {@value #REQUESTS_TOTAL}{@code {route,method,status_family}} — the "paths" view;
+ * - {@value #REQUEST_DURATION_SECONDS}{@code {route}} — per-route latency distribution;
+ * - {@value #ERRORS_TOTAL}{@code {route,category}} — the "errors" view, keyed by
+ * {@link EventCategory};
+ * - {@value #SECURITY_EVENTS_TOTAL}{@code {failure_type}} — the {@code cui-http}
+ * security-filter counts;
+ * - {@value #UPSTREAM_DURATION_SECONDS}{@code {route}} — downstream-call time, separated
+ * from gateway overhead.
+ *
+ * Route cardinality is bounded (route id is a config-fixed label; unmatched requests share the
+ * fixed {@value #NO_ROUTE} value), so every meter is safe to keep always on. Each record call
+ * resolves its meter through the {@link MeterRegistry}, which caches meters by name and tag set,
+ * so the recorder holds no per-meter state and is thread-safe by delegation.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+@ApplicationScoped
+public class SheriffMetrics {
+
+ /** Counter of requests per route, method, and status family — the "paths" view. */
+ public static final String REQUESTS_TOTAL = "sheriff_requests_total";
+ /** Per-route request latency distribution (timer). */
+ public static final String REQUEST_DURATION_SECONDS = "sheriff_request_duration_seconds";
+ /** Counter of rejected / failed requests keyed by {@link EventCategory}. */
+ public static final String ERRORS_TOTAL = "sheriff_errors_total";
+ /** The {@code cui-http} security-filter counts, per failure type. */
+ public static final String SECURITY_EVENTS_TOTAL = "sheriff_security_events_total";
+ /** Per-route downstream-call time (timer). */
+ public static final String UPSTREAM_DURATION_SECONDS = "sheriff_upstream_duration_seconds";
+
+ /** The bounded label value shared by requests that matched no route. */
+ public static final String NO_ROUTE = "";
+
+ private static final String TAG_ROUTE = "route";
+ private static final String TAG_METHOD = "method";
+ private static final String TAG_STATUS_FAMILY = "status_family";
+ private static final String TAG_CATEGORY = "category";
+ private static final String TAG_FAILURE_TYPE = "failure_type";
+
+ private final MeterRegistry registry;
+
+ /**
+ * @param registry the Micrometer registry the meters are registered against
+ */
+ @Inject
+ public SheriffMetrics(MeterRegistry registry) {
+ this.registry = Objects.requireNonNull(registry, "registry");
+ }
+
+ /**
+ * Counts one completed request against {@link #REQUESTS_TOTAL}.
+ *
+ * @param route the config-fixed route id, or {@link #NO_ROUTE} when unmatched
+ * @param method the request method (e.g. {@code GET})
+ * @param statusFamily the response status family ({@code 2xx} / {@code 3xx} / {@code 4xx} /
+ * {@code 5xx})
+ */
+ public void recordRequest(String route, String method, String statusFamily) {
+ registry.counter(REQUESTS_TOTAL,
+ TAG_ROUTE, route, TAG_METHOD, method, TAG_STATUS_FAMILY, statusFamily).increment();
+ }
+
+ /**
+ * Records the total time spent handling a request against {@link #REQUEST_DURATION_SECONDS}.
+ *
+ * @param route the config-fixed route id, or {@link #NO_ROUTE} when unmatched
+ * @param duration the elapsed request time
+ */
+ public void recordRequestDuration(String route, Duration duration) {
+ registry.timer(REQUEST_DURATION_SECONDS, TAG_ROUTE, route).record(duration);
+ }
+
+ /**
+ * Counts one rejected / failed request against {@link #ERRORS_TOTAL}, keyed by category slug.
+ *
+ * @param route the config-fixed route id, or {@link #NO_ROUTE} when unmatched
+ * @param category the failure category
+ */
+ public void recordError(String route, EventCategory category) {
+ registry.counter(ERRORS_TOTAL, TAG_ROUTE, route, TAG_CATEGORY, category.slug()).increment();
+ }
+
+ /**
+ * Binds the boot-shared {@code cui-http} {@link SecurityEventCounter} to the registry, exposing its
+ * per-{@link UrlSecurityFailureType} violation counts as the {@link #SECURITY_EVENTS_TOTAL} meter.
+ * One {@link FunctionCounter} is registered per failure type, so the {@code failure_type} label
+ * cardinality is fixed at the {@link UrlSecurityFailureType} enum (never operator-controlled input),
+ * keeping the meter safe to keep always on. Each function counter reads the live
+ * {@link SecurityEventCounter#getCount(UrlSecurityFailureType) count} the security-filter stages
+ * accumulate on the hot path, so a security-relevant rejection moves the meter on the next scrape
+ * without any per-request recording call.
+ *
+ * Called once at boot with the single {@link SecurityEventCounter} instance shared across the
+ * baseline / thorough security-filter stages and the forwarded-header resolver, so the meter
+ * reflects every {@code cui-http} violation the gateway counts, not only those observed at the edge.
+ *
+ * @param securityEventCounter the boot-shared cui-http security event counter (never a local instance)
+ */
+ public void bindSecurityEventCounter(SecurityEventCounter securityEventCounter) {
+ Objects.requireNonNull(securityEventCounter, "securityEventCounter");
+ for (UrlSecurityFailureType failureType : UrlSecurityFailureType.values()) {
+ FunctionCounter.builder(SECURITY_EVENTS_TOTAL, securityEventCounter,
+ counter -> counter.getCount(failureType))
+ .tag(TAG_FAILURE_TYPE, failureType.name())
+ .register(registry);
+ }
+ }
+
+ /**
+ * Records the time spent in the downstream call against {@link #UPSTREAM_DURATION_SECONDS}.
+ *
+ * @param route the config-fixed route id
+ * @param duration the elapsed upstream-call time
+ */
+ public void recordUpstreamDuration(String route, Duration duration) {
+ registry.timer(UPSTREAM_DURATION_SECONDS, TAG_ROUTE, route).record(duration);
+ }
+
+ /**
+ * Classifies an HTTP status code into its bounded {@code status_family} label
+ * ({@code 1xx} / {@code 2xx} / {@code 3xx} / {@code 4xx} / {@code 5xx}) for the
+ * {@link #REQUESTS_TOTAL} counter, keeping the {@code status_family} label cardinality fixed
+ * regardless of the concrete status.
+ *
+ * @param statusCode the response HTTP status code (e.g. {@code 200}, {@code 404})
+ * @return the leading-digit status family (e.g. {@code "2xx"}, {@code "4xx"})
+ */
+ public static String statusFamily(int statusCode) {
+ return (statusCode / 100) + "xx";
+ }
+}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/routing/HttpProtocolProcessor.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/routing/HttpProtocolProcessor.java
new file mode 100644
index 0000000..a53b79f
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/routing/HttpProtocolProcessor.java
@@ -0,0 +1,50 @@
+/*
+ * 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.routing;
+
+import java.util.EnumSet;
+import java.util.Set;
+
+
+import de.cuioss.sheriff.api.config.model.HttpMethod;
+
+/**
+ * The standard-verb HTTP processor. Serves every proxyable verb in {@link HttpMethod} and
+ * is reused for GraphQL-over-HTTP routes (the {@link ProtocolProcessorRegistry} maps both
+ * {@code HTTP} and {@code GRAPHQL} to a single shared instance).
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+public final class HttpProtocolProcessor implements ProtocolProcessor {
+
+ private static final Set STANDARD_METHODS = Set.copyOf(EnumSet.allOf(HttpMethod.class));
+
+ @Override
+ public String id() {
+ return "http";
+ }
+
+ @Override
+ public Set standardMethods() {
+ return STANDARD_METHODS;
+ }
+
+ @Override
+ public boolean supports(HttpMethod method) {
+ return STANDARD_METHODS.contains(method);
+ }
+}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/routing/ProtocolProcessor.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/routing/ProtocolProcessor.java
new file mode 100644
index 0000000..5e41990
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/routing/ProtocolProcessor.java
@@ -0,0 +1,48 @@
+/*
+ * 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.routing;
+
+import java.util.Set;
+
+
+import de.cuioss.sheriff.api.config.model.HttpMethod;
+
+/**
+ * Strategy carrying the verb semantics for a route's application protocol. The
+ * {@link ProtocolProcessorRegistry} selects one processor per route at boot; unsupported
+ * protocols are rejected at boot rather than represented here.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+public interface ProtocolProcessor {
+
+ /**
+ * @return the stable identifier of this processor (e.g. {@code "http"})
+ */
+ String id();
+
+ /**
+ * @return the standard verb set this processor serves
+ */
+ Set standardMethods();
+
+ /**
+ * @param method the request method
+ * @return {@code true} when this processor serves {@code method}
+ */
+ boolean supports(HttpMethod method);
+}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/routing/ProtocolProcessorRegistry.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/routing/ProtocolProcessorRegistry.java
new file mode 100644
index 0000000..f358c35
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/routing/ProtocolProcessorRegistry.java
@@ -0,0 +1,78 @@
+/*
+ * 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.routing;
+
+import java.util.Collections;
+import java.util.EnumMap;
+import java.util.Map;
+import java.util.Objects;
+
+
+import de.cuioss.sheriff.api.config.model.Protocol;
+import de.cuioss.sheriff.api.events.EventType;
+import de.cuioss.sheriff.api.events.GatewayException;
+
+/**
+ * Boot-time registry mapping each supported {@link Protocol} to its {@link ProtocolProcessor}.
+ * {@code HTTP} and {@code GRAPHQL} share a single {@link HttpProtocolProcessor} instance;
+ * {@code GRPC} and {@code WEBSOCKET} are deliberately absent, so {@link #require(Protocol, String)}
+ * fails boot with a clear not-yet-implemented error for a route requesting them.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+public final class ProtocolProcessorRegistry {
+
+ private final Map processors;
+
+ /**
+ * Builds the default registry: a shared HTTP processor for {@code HTTP} and {@code GRAPHQL}.
+ */
+ public ProtocolProcessorRegistry() {
+ HttpProtocolProcessor http = new HttpProtocolProcessor();
+ Map map = new EnumMap<>(Protocol.class);
+ map.put(Protocol.HTTP, http);
+ map.put(Protocol.GRAPHQL, http);
+ this.processors = Collections.unmodifiableMap(map);
+ }
+
+ /**
+ * Resolves the processor for a route's protocol, failing boot when the protocol is not
+ * yet implemented ({@code GRPC} / {@code WEBSOCKET}).
+ *
+ * @param protocol the route's effective protocol
+ * @param routeId the route id, for the failure message
+ * @return the processor serving {@code protocol}
+ * @throws GatewayException carrying {@link EventType#CONFIG_INVALID} when the protocol is unsupported
+ */
+ public ProtocolProcessor require(Protocol protocol, String routeId) {
+ Objects.requireNonNull(protocol, "protocol");
+ ProtocolProcessor processor = processors.get(protocol);
+ if (processor == null) {
+ throw new GatewayException(EventType.CONFIG_INVALID,
+ "Route '" + routeId + "' requests protocol " + protocol + " which is not yet implemented");
+ }
+ return processor;
+ }
+
+ /**
+ * @param protocol the protocol to test
+ * @return {@code true} when a processor is registered for {@code protocol}
+ */
+ public boolean supports(Protocol protocol) {
+ return processors.containsKey(protocol);
+ }
+}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/routing/RouteMatcher.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/routing/RouteMatcher.java
new file mode 100644
index 0000000..c9ecc16
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/routing/RouteMatcher.java
@@ -0,0 +1,134 @@
+/*
+ * 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.routing;
+
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+
+
+import de.cuioss.sheriff.api.config.model.HttpMethod;
+import de.cuioss.sheriff.api.config.model.MatchConfig;
+import de.cuioss.sheriff.api.config.model.MatchConfig.HeaderMatcher;
+
+import org.jspecify.annotations.Nullable;
+
+/**
+ * The compiled form of a route's {@code match} block, assembled once at boot. Matchers
+ * compose with AND semantics: a request matches only when the path is at or below the
+ * prefix on a segment boundary AND (when constrained) the method, host, and every header
+ * matcher hold.
+ *
+ * This is the match test only; the effective {@code allowed_methods} verb gate (405) is
+ * carried separately on {@link RouteRuntime}.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+public final class RouteMatcher {
+
+ private final String pathPrefix;
+ private final Set matchMethods;
+ private final Optional host;
+ private final List headers;
+
+ private RouteMatcher(String pathPrefix, Set matchMethods, Optional host,
+ List headers) {
+ this.pathPrefix = pathPrefix;
+ this.matchMethods = matchMethods;
+ this.host = host;
+ this.headers = headers;
+ }
+
+ /**
+ * Compiles a matcher from the resolved {@code match} block.
+ *
+ * @param match the resolved match configuration
+ * @return the compiled matcher
+ */
+ public static RouteMatcher from(MatchConfig match) {
+ Objects.requireNonNull(match, "match");
+ Set methods = match.methods().isEmpty()
+ ? EnumSet.noneOf(HttpMethod.class)
+ : EnumSet.copyOf(match.methods());
+ return new RouteMatcher(match.pathPrefix(), methods, match.host(), List.copyOf(match.headers()));
+ }
+
+ /**
+ * @return the route's literal {@code path_prefix}
+ */
+ public String pathPrefix() {
+ return pathPrefix;
+ }
+
+ /**
+ * Tests whether {@code path} is covered by this route's prefix on a segment boundary.
+ *
+ * @param path the request path
+ * @return {@code true} when the path is at or below the prefix
+ */
+ public boolean matchesPrefix(String path) {
+ Objects.requireNonNull(path, "path");
+ if (path.equals(pathPrefix)) {
+ return true;
+ }
+ return pathPrefix.endsWith("/") ? path.startsWith(pathPrefix) : path.startsWith(pathPrefix + "/");
+ }
+
+ /**
+ * Applies the full matcher set (prefix AND method AND host AND headers).
+ *
+ * @param path the request path
+ * @param method the request method
+ * @param requestHost the request host, {@code null} when absent
+ * @param requestHeaders the request headers, keyed by name
+ * @return {@code true} when every declared matcher holds
+ */
+ public boolean matches(String path, HttpMethod method, @Nullable String requestHost,
+ Map requestHeaders) {
+ Objects.requireNonNull(path, "path");
+ Objects.requireNonNull(method, "method");
+ Objects.requireNonNull(requestHeaders, "requestHeaders");
+ if (!matchesPrefix(path)) {
+ return false;
+ }
+ if (!matchMethods.isEmpty() && !matchMethods.contains(method)) {
+ return false;
+ }
+ if (host.isPresent() && !host.get().equals(requestHost)) {
+ return false;
+ }
+ return headersMatch(requestHeaders);
+ }
+
+ private boolean headersMatch(Map requestHeaders) {
+ for (HeaderMatcher header : headers) {
+ String actual = requestHeaders.get(header.name());
+ Optional expectedValue = header.value();
+ if (expectedValue.isPresent()) {
+ if (!expectedValue.get().equals(actual)) {
+ return false;
+ }
+ } else if (header.present().orElse(Boolean.FALSE) && actual == null) {
+ return false;
+ }
+ }
+ return true;
+ }
+}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/routing/RouteRuntime.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/routing/RouteRuntime.java
new file mode 100644
index 0000000..290d5ea
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/routing/RouteRuntime.java
@@ -0,0 +1,113 @@
+/*
+ * 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.routing;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+
+
+import de.cuioss.http.security.config.SecurityConfiguration;
+import de.cuioss.sheriff.api.config.model.AuthConfig;
+import de.cuioss.sheriff.api.config.model.ForwardConfig;
+import de.cuioss.sheriff.api.config.model.HttpMethod;
+import de.cuioss.sheriff.api.config.model.Protocol;
+import de.cuioss.sheriff.api.config.model.ResolvedUpstream;
+import de.cuioss.sheriff.api.config.model.SecurityHeadersConfig;
+
+import io.smallrye.faulttolerance.api.Guard;
+import io.vertx.core.http.HttpClient;
+import lombok.Builder;
+import lombok.Getter;
+
+/**
+ * The immutable, boot-time-compiled runtime for one route. The request pipeline consumes
+ * these already-resolved values on the hot path and never re-derives inheritance.
+ *
+ * Framework-coupled by design (operator resolution 2026-07-19). Unlike the
+ * agnostic core, this type holds the shared data-plane Vert.x {@link HttpClient} reference and
+ * the per-route SmallRye Fault-Tolerance {@link Guard} directly — there is no separate
+ * {@code edge.RouteBinding}. Consequently the {@code routing} package is excluded from the
+ * ADR-0005 framework-agnostic arch-gate. The client and guard are shared instances handed in
+ * by the {@code RouteRuntimeAssembler}; this type holds references, it does not construct them.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+@Builder
+@Getter
+public final class RouteRuntime {
+
+ /** The route id (also the bounded metrics {@code route} label). */
+ private final String id;
+
+ /** The effective protocol. */
+ private final Protocol protocol;
+
+ /** The compiled matcher set (prefix, methods, host, headers). */
+ private final RouteMatcher matcher;
+
+ /** The protocol processor serving this route (shared across same-protocol routes). */
+ private final ProtocolProcessor protocolProcessor;
+
+ /** The effective {@code allowed_methods} verb allowlist for the stage-2b verb gate (405). */
+ private final Set effectiveAllowedMethods;
+
+ /** The materialized auth posture. */
+ private final AuthConfig effectiveAuth;
+
+ /** The required scopes enforced for this route (empty when none). */
+ private final List requiredScopes;
+
+ /** The deduplicated cui-http security configuration, empty when the route declares none. */
+ private final Optional securityConfiguration;
+
+ /** The effective response-header posture, empty when none resolves. */
+ private final Optional securityHeaders;
+
+ /**
+ * The effective, deny-by-default {@code forward} allowlist consumed by stage 5 — the
+ * per-route {@code headers_allow} / {@code query_allow} / {@code set_headers} sets resolved
+ * once at boot. An empty {@link ForwardConfig} when the route declares no {@code forward} block.
+ */
+ @Builder.Default
+ private final ForwardConfig effectiveForward = ForwardConfig.builder().build();
+
+ /**
+ * The effective {@code allowed_paths} whitelist enforced by stage 3 — the per-route path
+ * allowlist resolved once at boot from the route's {@code security_filter} block. Empty when
+ * the route declares no whitelist (unrestricted). Carried separately from
+ * {@link #securityConfiguration} because {@code allowed_paths} is a gateway concept the cui-http
+ * {@link SecurityConfiguration} does not model.
+ */
+ @Builder.Default
+ private final List effectiveAllowedPaths = List.of();
+
+ /** The materialized upstream-retry toggle. */
+ private final boolean retryEnabled;
+
+ /** The materialized HTTP-304 not-modified toggle. */
+ private final boolean notModifiedEnabled;
+
+ /** The resolved upstream target. */
+ private final ResolvedUpstream upstream;
+
+ /** The shared Vert.x client for this route's upstream tuple (one instance per tuple). */
+ private final HttpClient httpClient;
+
+ /** The shared SmallRye Fault-Tolerance guard for this route's resilience shape. */
+ private final Guard resilienceGuard;
+}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/routing/package-info.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/routing/package-info.java
new file mode 100644
index 0000000..6e265c8
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/routing/package-info.java
@@ -0,0 +1,39 @@
+/*
+ * 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.
+ */
+/**
+ * The compiled per-route runtime model.
+ *
+ * {@link de.cuioss.sheriff.api.routing.RouteRuntime} is the immutable, boot-assembled runtime
+ * for one route; {@link de.cuioss.sheriff.api.routing.RouteMatcher} is its compiled matcher;
+ * and the {@link de.cuioss.sheriff.api.routing.ProtocolProcessor} /
+ * {@link de.cuioss.sheriff.api.routing.HttpProtocolProcessor} /
+ * {@link de.cuioss.sheriff.api.routing.ProtocolProcessorRegistry} triad selects and shares
+ * the protocol strategy, rejecting unsupported protocols at boot.
+ *
+ * Deliberately framework-coupled (operator resolution 2026-07-19). Because
+ * {@code RouteRuntime} holds the shared Vert.x {@code HttpClient} reference and the per-route
+ * SmallRye Fault-Tolerance guard directly, this package is excluded from the ADR-0005
+ * framework-agnostic arch-gate rule set (which covers {@code config.model},
+ * {@code config.validation}, {@code events}, {@code forward}, and {@code pipeline} only). The
+ * deviation is recorded in {@code architecture.adoc}.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+@NullMarked
+package de.cuioss.sheriff.api.routing;
+
+import org.jspecify.annotations.NullMarked;
diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/api/ApiSheriffTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/api/ApiSheriffTest.java
deleted file mode 100644
index 3d172ab..0000000
--- a/api-sheriff/src/test/java/de/cuioss/sheriff/api/ApiSheriffTest.java
+++ /dev/null
@@ -1,33 +0,0 @@
-/*
- * 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;
-
-import static org.junit.jupiter.api.Assertions.assertEquals;
-
-
-import org.junit.jupiter.api.Test;
-
-/**
- * Test class for {@link ApiSheriff}.
- */
-class ApiSheriffTest {
-
- @Test
- void shouldReturnStatus() {
- ApiSheriff sheriff = new ApiSheriff();
- assertEquals("API Sheriff is operational", sheriff.getStatus());
- }
-}
diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/api/arch/FrameworkAgnosticArchTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/api/arch/FrameworkAgnosticArchTest.java
new file mode 100644
index 0000000..87b5f6e
--- /dev/null
+++ b/api-sheriff/src/test/java/de/cuioss/sheriff/api/arch/FrameworkAgnosticArchTest.java
@@ -0,0 +1,100 @@
+/*
+ * 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.arch;
+
+import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import com.tngtech.archunit.core.domain.JavaClasses;
+import com.tngtech.archunit.core.importer.ClassFileImporter;
+import com.tngtech.archunit.core.importer.ImportOption;
+import com.tngtech.archunit.lang.ArchRule;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+/**
+ * ADR-0005 framework-agnostic package-boundary gate. Asserts that the agnostic
+ * core packages ({@code config.model}, {@code config.validation}, {@code events},
+ * {@code forward}, {@code pipeline}) carry no framework imports, so the request
+ * pipeline stays portable across the framework edge.
+ *
+ * The {@code routing} package is deliberately excluded from the agnostic set
+ * (operator resolution 2026-07-19): {@code routing.RouteRuntime} holds the shared Vert.x
+ * {@code HttpClient} reference and the per-route SmallRye Fault-Tolerance guard by design,
+ * so it is framework-coupled and must NOT be asserted framework-agnostic here.
+ *
+ * This is a plain JUnit 5 test (no ArchUnit {@code @AnalyzeClasses} runner) so it runs in
+ * both {@code test} and {@code verify -Ppre-commit}, wiring the boundary into the quality gate.
+ *
+ * @since 1.0
+ */
+class FrameworkAgnosticArchTest {
+
+ private static final String BASE_PACKAGE = "de.cuioss.sheriff.api";
+
+ /**
+ * The agnostic core packages the ADR-0005 gate protects. {@code routing} is intentionally
+ * absent — see the class Javadoc.
+ */
+ private static final String[] AGNOSTIC_PACKAGES = {
+ "de.cuioss.sheriff.api.config.model..",
+ "de.cuioss.sheriff.api.config.validation..",
+ "de.cuioss.sheriff.api.events..",
+ "de.cuioss.sheriff.api.forward..",
+ "de.cuioss.sheriff.api.pipeline.."
+ };
+
+ /** Framework packages that an agnostic-core class must never depend on. */
+ private static final String[] FRAMEWORK_PACKAGES = {
+ "io.quarkus..",
+ "io.vertx..",
+ "jakarta.enterprise..",
+ "jakarta.inject..",
+ "org.eclipse.microprofile..",
+ "io.micrometer.."
+ };
+
+ private static final JavaClasses PRODUCTION_CLASSES = new ClassFileImporter()
+ .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS)
+ .importPackages(BASE_PACKAGE);
+
+ @Test
+ @DisplayName("ADR-0005: agnostic core packages must not depend on framework packages")
+ void agnosticPackagesMustNotDependOnFrameworks() {
+ ArchRule rule = noClasses()
+ .that().resideInAnyPackage(AGNOSTIC_PACKAGES)
+ .should().dependOnClassesThat().resideInAnyPackage(FRAMEWORK_PACKAGES)
+ .because("ADR-0005 requires config.model, config.validation, events, forward, and "
+ + "pipeline to remain framework-agnostic (routing is excluded by design)")
+ .allowEmptyShould(true);
+
+ rule.check(PRODUCTION_CLASSES);
+ }
+
+ @Test
+ @DisplayName("ADR-0005 gate detects a deliberate framework dependency (negative control)")
+ void gateFailsOnFrameworkDependency() {
+ ArchRule ruleAgainstFrameworkCoupledPackage = noClasses()
+ .that().resideInAPackage("de.cuioss.sheriff.api.quarkus..")
+ .should().dependOnClassesThat().resideInAnyPackage(FRAMEWORK_PACKAGES)
+ .allowEmptyShould(true);
+
+ assertThrows(AssertionError.class,
+ () -> ruleAgainstFrameworkCoupledPackage.check(PRODUCTION_CLASSES),
+ "The gate must fail when a covered package depends on a framework package — "
+ + "the framework-coupled quarkus package is the standing negative control");
+ }
+}
diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/api/auth/AuthenticationStageTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/api/auth/AuthenticationStageTest.java
new file mode 100644
index 0000000..34ddcb3
--- /dev/null
+++ b/api-sheriff/src/test/java/de/cuioss/sheriff/api/auth/AuthenticationStageTest.java
@@ -0,0 +1,152 @@
+/*
+ * 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 static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.util.List;
+import java.util.Map;
+
+
+import de.cuioss.sheriff.api.config.model.AuthConfig;
+import de.cuioss.sheriff.api.config.model.HttpMethod;
+import de.cuioss.sheriff.api.events.EventType;
+import de.cuioss.sheriff.api.events.GatewayException;
+import de.cuioss.sheriff.api.pipeline.PipelineRequest;
+import de.cuioss.sheriff.api.routing.RouteRuntime;
+import de.cuioss.sheriff.token.validation.TokenValidator;
+import de.cuioss.sheriff.token.validation.test.TestTokenHolder;
+import de.cuioss.sheriff.token.validation.test.generator.TestTokenGenerators;
+import de.cuioss.test.generator.junit.EnableGeneratorController;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+@EnableGeneratorController
+@DisplayName("AuthenticationStage — stage 4 offline bearer-token validation")
+class AuthenticationStageTest {
+
+ private static final String ABSENT_SCOPE = "gateway:definitely-absent-scope-xyz";
+
+ @Test
+ @DisplayName("passes a require:none route without inspecting any token")
+ void passesRequireNone() {
+ // Arrange
+ AuthenticationStage stage = stageFor(TestTokenGenerators.accessTokens().next());
+ PipelineRequest request = request(authConfig("none", List.of()), Map.of());
+
+ // Act + Assert
+ assertDoesNotThrow(() -> stage.process(request));
+ }
+
+ @Test
+ @DisplayName("passes a require:none route without ever resolving the lazy validator")
+ void passesRequireNoneWithoutResolvingValidator() {
+ // Arrange — a provider that fails when resolved proves that a require:none route never
+ // triggers the (potentially config-absent) validator producer via Provider#get().
+ AuthenticationStage stage = new AuthenticationStage(() -> {
+ throw new AssertionError("require:none must not resolve the token validator");
+ });
+ PipelineRequest request = request(authConfig("none", List.of()), Map.of());
+
+ // Act + Assert
+ assertDoesNotThrow(() -> stage.process(request));
+ }
+
+ @Test
+ @DisplayName("accepts a valid bearer token on a require:bearer route")
+ void acceptsValidBearerToken() {
+ // Arrange
+ TestTokenHolder holder = TestTokenGenerators.accessTokens().next();
+ AuthenticationStage stage = stageFor(holder);
+ PipelineRequest request = bearerRequest(holder.getRawToken(), authConfig("bearer", List.of()));
+
+ // Act + Assert
+ assertDoesNotThrow(() -> stage.process(request));
+ }
+
+ @Test
+ @DisplayName("rejects a missing bearer token 401 with WWW-Authenticate")
+ void rejectsMissingBearerToken() {
+ // Arrange
+ AuthenticationStage stage = stageFor(TestTokenGenerators.accessTokens().next());
+ PipelineRequest request = request(authConfig("bearer", List.of()), Map.of());
+
+ // Act
+ GatewayException thrown = assertThrows(GatewayException.class, () -> stage.process(request));
+
+ // Assert
+ assertEquals(EventType.TOKEN_MISSING, thrown.getEventType());
+ assertEquals("Bearer", request.responseHeaders().get("WWW-Authenticate"));
+ }
+
+ @Test
+ @DisplayName("rejects a malformed bearer token 401 with WWW-Authenticate")
+ void rejectsInvalidBearerToken() {
+ // Arrange
+ AuthenticationStage stage = stageFor(TestTokenGenerators.accessTokens().next());
+ PipelineRequest request = bearerRequest("not.a.valid.jwt", authConfig("bearer", List.of()));
+
+ // Act
+ GatewayException thrown = assertThrows(GatewayException.class, () -> stage.process(request));
+
+ // Assert
+ assertEquals(EventType.TOKEN_INVALID, thrown.getEventType());
+ assertEquals("Bearer", request.responseHeaders().get("WWW-Authenticate"));
+ }
+
+ @Test
+ @DisplayName("rejects a valid token lacking a required scope 403")
+ void rejectsMissingScope() {
+ // Arrange
+ TestTokenHolder holder = TestTokenGenerators.accessTokens().next();
+ AuthenticationStage stage = stageFor(holder);
+ PipelineRequest request = bearerRequest(holder.getRawToken(), authConfig("bearer", List.of(ABSENT_SCOPE)));
+
+ // Act
+ GatewayException thrown = assertThrows(GatewayException.class, () -> stage.process(request));
+
+ // Assert
+ assertEquals(EventType.SCOPE_MISSING, thrown.getEventType());
+ }
+
+ private static AuthenticationStage stageFor(TestTokenHolder holder) {
+ TokenValidator validator = TokenValidator.builder().issuerConfig(holder.getIssuerConfig()).build();
+ return new AuthenticationStage(() -> validator);
+ }
+
+ private static AuthConfig authConfig(String require, List requiredScopes) {
+ return AuthConfig.builder().require(require).requiredScopes(requiredScopes).build();
+ }
+
+ private static PipelineRequest bearerRequest(String token, AuthConfig auth) {
+ return request(auth, Map.of("authorization", List.of("Bearer " + token)));
+ }
+
+ private static PipelineRequest request(AuthConfig auth, Map> headers) {
+ PipelineRequest request = PipelineRequest.builder()
+ .method(HttpMethod.GET)
+ .requestPath("/api/orders")
+ .queryParameters(Map.of())
+ .headers(headers)
+ .build();
+ request.canonicalPath("/api/orders");
+ request.selectedRoute(RouteRuntime.builder().id("orders").effectiveAuth(auth).build());
+ return request;
+ }
+}
diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/api/auth/TokenValidatorProducerTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/api/auth/TokenValidatorProducerTest.java
new file mode 100644
index 0000000..2626f5f
--- /dev/null
+++ b/api-sheriff/src/test/java/de/cuioss/sheriff/api/auth/TokenValidatorProducerTest.java
@@ -0,0 +1,164 @@
+/*
+ * 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 static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.util.List;
+import java.util.Optional;
+
+
+import de.cuioss.sheriff.api.config.model.GatewayConfig;
+import de.cuioss.sheriff.api.config.model.IssuerConfig;
+import de.cuioss.sheriff.api.config.model.TokenValidationConfig;
+import de.cuioss.sheriff.api.events.EventType;
+import de.cuioss.sheriff.api.events.GatewayException;
+import de.cuioss.sheriff.token.validation.TokenValidator;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+@DisplayName("TokenValidatorProducer — builds the shared gateway validator from token_validation")
+class TokenValidatorProducerTest {
+
+ private static final String ISSUER = "https://issuer.example";
+ private static final String JWKS_URL = "https://issuer.example/jwks";
+
+ @Test
+ @DisplayName("fails config-invalid when no token_validation block is present")
+ void failsWhenTokenValidationAbsent() {
+ // Arrange
+ TokenValidatorProducer producer = new TokenValidatorProducer(GatewayConfig.builder().version(1).build());
+
+ // Act
+ GatewayException thrown = assertThrows(GatewayException.class, producer::gatewayTokenValidator);
+
+ // Assert
+ assertEquals(EventType.CONFIG_INVALID, thrown.getEventType());
+ }
+
+ @Test
+ @DisplayName("builds a validator from an http-JWKS issuer with an explicit expected audience")
+ void buildsFromHttpIssuerWithAudience() {
+ // Arrange
+ TokenValidatorProducer producer = producerFor(IssuerConfig.builder()
+ .name("primary")
+ .issuer(ISSUER)
+ .audience(Optional.of("api-sheriff"))
+ .jwks(Optional.of(IssuerConfig.Jwks.builder().source("http").url(Optional.of(JWKS_URL)).build()))
+ .build());
+
+ // Act
+ TokenValidator validator = producer.gatewayTokenValidator();
+
+ // Assert
+ assertNotNull(validator, "an http issuer with a jwks url yields a built validator");
+ }
+
+ @Test
+ @DisplayName("builds a validator from an http-JWKS issuer that configures no audience (validation disabled)")
+ void buildsFromHttpIssuerWithoutAudience() {
+ // Arrange — no audience means audience validation is explicitly disabled at build time
+ TokenValidatorProducer producer = producerFor(IssuerConfig.builder()
+ .name("primary")
+ .issuer(ISSUER)
+ .jwks(Optional.of(IssuerConfig.Jwks.builder().source("http").url(Optional.of(JWKS_URL)).build()))
+ .build());
+
+ // Act
+ TokenValidator validator = producer.gatewayTokenValidator();
+
+ // Assert
+ assertNotNull(validator, "an audience-less issuer still yields a built validator");
+ }
+
+ @Test
+ @DisplayName("fails config-invalid when an issuer declares no jwks source")
+ void failsWhenIssuerHasNoJwks() {
+ // Arrange
+ TokenValidatorProducer producer = producerFor(IssuerConfig.builder()
+ .name("primary")
+ .issuer(ISSUER)
+ .build());
+
+ // Act
+ GatewayException thrown = assertThrows(GatewayException.class, producer::gatewayTokenValidator);
+
+ // Assert
+ assertEquals(EventType.CONFIG_INVALID, thrown.getEventType());
+ }
+
+ @Test
+ @DisplayName("fails config-invalid when an http jwks source declares no url")
+ void failsWhenHttpJwksHasNoUrl() {
+ // Arrange
+ TokenValidatorProducer producer = producerFor(IssuerConfig.builder()
+ .name("primary")
+ .issuer(ISSUER)
+ .jwks(Optional.of(IssuerConfig.Jwks.builder().source("http").build()))
+ .build());
+
+ // Act
+ GatewayException thrown = assertThrows(GatewayException.class, producer::gatewayTokenValidator);
+
+ // Assert
+ assertEquals(EventType.CONFIG_INVALID, thrown.getEventType());
+ }
+
+ @Test
+ @DisplayName("fails config-invalid when a file jwks source declares no file path")
+ void failsWhenFileJwksHasNoFile() {
+ // Arrange
+ TokenValidatorProducer producer = producerFor(IssuerConfig.builder()
+ .name("primary")
+ .issuer(ISSUER)
+ .jwks(Optional.of(IssuerConfig.Jwks.builder().source("file").build()))
+ .build());
+
+ // Act
+ GatewayException thrown = assertThrows(GatewayException.class, producer::gatewayTokenValidator);
+
+ // Assert
+ assertEquals(EventType.CONFIG_INVALID, thrown.getEventType());
+ }
+
+ @Test
+ @DisplayName("fails config-invalid for an unsupported jwks source")
+ void failsForUnsupportedJwksSource() {
+ // Arrange
+ TokenValidatorProducer producer = producerFor(IssuerConfig.builder()
+ .name("primary")
+ .issuer(ISSUER)
+ .jwks(Optional.of(IssuerConfig.Jwks.builder().source("ldap").build()))
+ .build());
+
+ // Act
+ GatewayException thrown = assertThrows(GatewayException.class, producer::gatewayTokenValidator);
+
+ // Assert
+ assertEquals(EventType.CONFIG_INVALID, thrown.getEventType());
+ }
+
+ private static TokenValidatorProducer producerFor(IssuerConfig issuer) {
+ GatewayConfig config = GatewayConfig.builder()
+ .version(1)
+ .tokenValidation(Optional.of(new TokenValidationConfig(List.of(issuer))))
+ .build();
+ return new TokenValidatorProducer(config);
+ }
+}
diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/api/config/RouteTableBuilderTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/api/config/RouteTableBuilderTest.java
index dca29ac..b99aa7c 100644
--- a/api-sheriff/src/test/java/de/cuioss/sheriff/api/config/RouteTableBuilderTest.java
+++ b/api-sheriff/src/test/java/de/cuioss/sheriff/api/config/RouteTableBuilderTest.java
@@ -29,14 +29,10 @@
import java.util.Optional;
-import org.junit.jupiter.api.DisplayName;
-import org.junit.jupiter.api.Nested;
-import org.junit.jupiter.api.Test;
-import org.junit.jupiter.params.ParameterizedTest;
-
import de.cuioss.sheriff.api.config.model.AnchorConfig;
import de.cuioss.sheriff.api.config.model.AuthConfig;
import de.cuioss.sheriff.api.config.model.EndpointConfig;
+import de.cuioss.sheriff.api.config.model.ForwardConfig;
import de.cuioss.sheriff.api.config.model.GatewayConfig;
import de.cuioss.sheriff.api.config.model.HttpMethod;
import de.cuioss.sheriff.api.config.model.MatchConfig;
@@ -57,6 +53,11 @@
import de.cuioss.test.juli.TestLogLevel;
import de.cuioss.test.juli.junit5.EnableTestLogger;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+
/**
* Tests for {@link RouteTableBuilder}: enabled-only merge, longest-prefix
* ordering over normalized prefixes (same-prefix disjointness now lives in
@@ -648,6 +649,41 @@ void shouldInheritWhenPerRouteToggleAbsent() {
}
}
+ @Nested
+ @DisplayName("Effective forward-policy resolution")
+ class EffectiveForward {
+
+ @Test
+ @DisplayName("Should carry the route-level forward block onto the resolved route")
+ void shouldCarryRouteForward() {
+ ForwardConfig forward = new ForwardConfig(List.of("Accept"), List.of("page"),
+ Map.of("X-Gateway", "api-sheriff"));
+ RouteConfig route = RouteConfig.builder().id("r").match(match("/r", HttpMethod.GET))
+ .forward(Optional.of(forward)).build();
+ EndpointConfig endpoint = endpoint("orders", "ORDERS").routes(List.of(route)).build();
+
+ RouteTable table = builder.build(gateway().build(), List.of(endpoint), topologyWith("ORDERS"));
+
+ ResolvedRoute resolved = find(table, "r");
+ assertEquals(forward, resolved.effectiveForward(), "the route forward block flows through unchanged");
+ }
+
+ @Test
+ @DisplayName("Should default an absent forward block to a deny-by-default empty allowlist")
+ void shouldDefaultAbsentForwardToEmpty() {
+ EndpointConfig endpoint = endpoint("orders", "ORDERS")
+ .routes(List.of(route("r", HttpMethod.GET))).build();
+
+ RouteTable table = builder.build(gateway().build(), List.of(endpoint), topologyWith("ORDERS"));
+
+ ResolvedRoute resolved = find(table, "r");
+ assertTrue(resolved.effectiveForward().headersAllow().isEmpty(),
+ "an unforwarded route resolves to an empty, deny-by-default allowlist");
+ assertTrue(resolved.effectiveForward().queryAllow().isEmpty());
+ assertTrue(resolved.effectiveForward().setHeaders().isEmpty());
+ }
+ }
+
@Nested
@DisplayName("Invariants over generated prefix / host / method permutations")
class PropertyBasedInvariants {
diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/api/config/load/ConfigLoaderTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/api/config/load/ConfigLoaderTest.java
index 4925826..816c862 100644
--- a/api-sheriff/src/test/java/de/cuioss/sheriff/api/config/load/ConfigLoaderTest.java
+++ b/api-sheriff/src/test/java/de/cuioss/sheriff/api/config/load/ConfigLoaderTest.java
@@ -31,9 +31,6 @@
import java.util.Optional;
-import org.junit.jupiter.api.Test;
-import org.junit.jupiter.api.io.TempDir;
-
import de.cuioss.sheriff.api.config.model.AnchorConfig;
import de.cuioss.sheriff.api.config.model.EndpointConfig;
import de.cuioss.sheriff.api.config.model.GatewayConfig;
@@ -42,6 +39,9 @@
import de.cuioss.sheriff.api.config.model.RouteConfig;
import de.cuioss.sheriff.api.config.model.UpstreamDefaultsConfig;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
/**
* Tests for {@link ConfigLoader}: binding a valid {@code gateway.yaml} (including
* secret resolution and the flattened {@code upstream_defaults} block), endpoint
diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/api/config/load/EnvSecretResolverTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/api/config/load/EnvSecretResolverTest.java
index 03e5d08..37bf5bc 100644
--- a/api-sheriff/src/test/java/de/cuioss/sheriff/api/config/load/EnvSecretResolverTest.java
+++ b/api-sheriff/src/test/java/de/cuioss/sheriff/api/config/load/EnvSecretResolverTest.java
@@ -23,14 +23,14 @@
import java.util.Map;
-import org.junit.jupiter.api.DisplayName;
-import org.junit.jupiter.api.Test;
-
import de.cuioss.sheriff.api.config.load.EnvSecretResolver.MalformedPlaceholderException;
import de.cuioss.sheriff.api.config.load.EnvSecretResolver.MissingVariableException;
import de.cuioss.test.generator.Generators;
import de.cuioss.test.generator.junit.EnableGeneratorController;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
/**
* Tests for {@link EnvSecretResolver}, the single D4 substitution engine: reference
* detection, bare-reference classification for the secrets rule, required
diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/api/config/model/ConfigModelContractTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/api/config/model/ConfigModelContractTest.java
index f51160f..ad81f0a 100644
--- a/api-sheriff/src/test/java/de/cuioss/sheriff/api/config/model/ConfigModelContractTest.java
+++ b/api-sheriff/src/test/java/de/cuioss/sheriff/api/config/model/ConfigModelContractTest.java
@@ -29,7 +29,6 @@
import java.util.Optional;
import java.util.stream.Stream;
-
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Named;
import org.junit.jupiter.api.Nested;
@@ -190,6 +189,7 @@ private static ResolvedRoute resolvedRoute() {
.retryEnabled(true)
.notModifiedEnabled(true)
.upstream(resolvedUpstream())
+ .effectiveForward(new ForwardConfig(List.of("Accept"), List.of("page"), Map.of("X-Gateway", "api-sheriff")))
.build();
}
@@ -444,12 +444,16 @@ void anchorConfigNormalizesAbsentComponents() {
@Test
void resolvedRouteNormalizesAbsentOptionals() {
ResolvedRoute cfg = new ResolvedRoute("id", null, null, matchConfig(), auth(), null, null, null, true,
- true, resolvedUpstream());
+ true, resolvedUpstream(), null);
assertTrue(cfg.anchor().isEmpty());
assertTrue(cfg.effectiveSecurityFilter().isEmpty());
assertTrue(cfg.effectiveSecurityHeaders().isEmpty());
assertTrue(cfg.effectiveAllowedMethods().isEmpty());
assertEquals(Protocol.HTTP, cfg.protocol());
+ assertTrue(cfg.effectiveForward().headersAllow().isEmpty(),
+ "an absent forward block normalizes to a deny-by-default empty allowlist");
+ assertTrue(cfg.effectiveForward().queryAllow().isEmpty());
+ assertTrue(cfg.effectiveForward().setHeaders().isEmpty());
}
@Test
@@ -591,13 +595,14 @@ void routeConfigRequiresIdAndMatch() {
void resolvedRouteRequiresIdMatchAuthAndUpstream() {
assertThrows(NullPointerException.class, () -> new ResolvedRoute(null, Protocol.HTTP, Optional.empty(),
matchConfig(), auth(), List.of(), Optional.empty(), Optional.empty(), true, true,
- resolvedUpstream()));
+ resolvedUpstream(), null));
assertThrows(NullPointerException.class, () -> new ResolvedRoute("id", Protocol.HTTP, Optional.empty(), null,
- auth(), List.of(), Optional.empty(), Optional.empty(), true, true, resolvedUpstream()));
+ auth(), List.of(), Optional.empty(), Optional.empty(), true, true, resolvedUpstream(), null));
assertThrows(NullPointerException.class, () -> new ResolvedRoute("id", Protocol.HTTP, Optional.empty(),
- matchConfig(), null, List.of(), Optional.empty(), Optional.empty(), true, true, resolvedUpstream()));
+ matchConfig(), null, List.of(), Optional.empty(), Optional.empty(), true, true, resolvedUpstream(),
+ null));
assertThrows(NullPointerException.class, () -> new ResolvedRoute("id", Protocol.HTTP, Optional.empty(),
- matchConfig(), auth(), List.of(), Optional.empty(), Optional.empty(), true, true, null));
+ matchConfig(), auth(), List.of(), Optional.empty(), Optional.empty(), true, true, null, null));
}
}
@@ -656,6 +661,8 @@ void resolvedRouteCarriesTheMaterializedEffectivePosture() {
assertEquals(List.of(HttpMethod.GET, HttpMethod.POST), cfg.effectiveAllowedMethods());
assertTrue(cfg.effectiveSecurityFilter().isPresent());
assertTrue(cfg.effectiveSecurityHeaders().isPresent());
+ assertEquals(List.of("Accept"), cfg.effectiveForward().headersAllow(),
+ "the materialized forward allowlist is carried on the resolved route");
}
@Test
diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/api/config/topology/TopologyResolverTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/api/config/topology/TopologyResolverTest.java
index ad34904..6f665fa 100644
--- a/api-sheriff/src/test/java/de/cuioss/sheriff/api/config/topology/TopologyResolverTest.java
+++ b/api-sheriff/src/test/java/de/cuioss/sheriff/api/config/topology/TopologyResolverTest.java
@@ -31,13 +31,6 @@
import java.util.stream.Stream;
-import org.junit.jupiter.api.DisplayName;
-import org.junit.jupiter.api.Test;
-import org.junit.jupiter.api.io.TempDir;
-import org.junit.jupiter.params.ParameterizedTest;
-import org.junit.jupiter.params.provider.Arguments;
-import org.junit.jupiter.params.provider.MethodSource;
-
import de.cuioss.sheriff.api.config.load.EnvSecretResolver;
import de.cuioss.sheriff.api.config.model.AuthConfig;
import de.cuioss.sheriff.api.config.model.EndpointConfig;
@@ -45,6 +38,13 @@
import de.cuioss.sheriff.api.config.model.ResolvedUpstream;
import de.cuioss.sheriff.api.config.topology.TopologyResolver.TopologyResolutionException;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
/**
* Tests for {@link TopologyResolver}: file resolution, URL decomposition with
* scheme-default ports, the D4 {@code ${VAR}} / {@code ${VAR:-default}} substitution
diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/api/config/validation/ConfigValidatorTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/api/config/validation/ConfigValidatorTest.java
index 50ff597..06c8c3c 100644
--- a/api-sheriff/src/test/java/de/cuioss/sheriff/api/config/validation/ConfigValidatorTest.java
+++ b/api-sheriff/src/test/java/de/cuioss/sheriff/api/config/validation/ConfigValidatorTest.java
@@ -26,11 +26,6 @@
import java.util.Optional;
-import org.junit.jupiter.api.DisplayName;
-import org.junit.jupiter.api.Nested;
-import org.junit.jupiter.api.Test;
-import org.junit.jupiter.params.ParameterizedTest;
-
import de.cuioss.sheriff.api.config.RouteTableBuilder;
import de.cuioss.sheriff.api.config.load.ConfigError;
import de.cuioss.sheriff.api.config.model.AnchorConfig;
@@ -57,6 +52,11 @@
import de.cuioss.test.juli.TestLoggerFactory;
import de.cuioss.test.juli.junit5.EnableTestLogger;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+
/**
* Tests for {@link ConfigValidator}: one negative case per enforced cross-cutting
* rule (including the seven ADR-0007 anchor / effective-auth rules), the D5
diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/api/edge/DispatchStageTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/api/edge/DispatchStageTest.java
new file mode 100644
index 0000000..143f5be
--- /dev/null
+++ b/api-sheriff/src/test/java/de/cuioss/sheriff/api/edge/DispatchStageTest.java
@@ -0,0 +1,322 @@
+/*
+ * 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.edge;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.time.temporal.ChronoUnit;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.concurrent.atomic.AtomicReference;
+
+
+import de.cuioss.sheriff.api.config.model.HttpMethod;
+import de.cuioss.sheriff.api.config.model.ResolvedUpstream;
+import de.cuioss.sheriff.api.events.EventType;
+import de.cuioss.sheriff.api.events.GatewayEventCounter;
+import de.cuioss.sheriff.api.events.GatewayException;
+
+import io.smallrye.faulttolerance.api.Guard;
+import io.vertx.core.Handler;
+import io.vertx.core.buffer.Buffer;
+import io.vertx.core.http.HttpClientResponse;
+import io.vertx.core.streams.ReadStream;
+import org.jspecify.annotations.Nullable;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+@DisplayName("DispatchStage — stage 6 streamed upstream dispatch")
+class DispatchStageTest {
+
+ @Nested
+ @DisplayName("upstream request URI assembly")
+ class UpstreamUri {
+
+ private static final ResolvedUpstream UPSTREAM = new ResolvedUpstream("http", "orders-svc", 8080, "/base");
+
+ @Test
+ @DisplayName("appends path remainder and raw query to the upstream base path")
+ void appendsRemainderAndQuery() {
+ assertEquals("/base/orders?page=2",
+ DispatchStage.upstreamRequestUri(UPSTREAM, "/orders", "?page=2"));
+ }
+
+ @Test
+ @DisplayName("omits the query when none is present")
+ void omitsEmptyQuery() {
+ assertEquals("/base/orders", DispatchStage.upstreamRequestUri(UPSTREAM, "/orders", ""));
+ }
+
+ @Test
+ @DisplayName("strips a trailing slash on the upstream base path before appending")
+ void stripsTrailingBasePathSlash() {
+ ResolvedUpstream slashed = new ResolvedUpstream("http", "orders-svc", 8080, "/base/");
+ assertEquals("/base/orders", DispatchStage.upstreamRequestUri(slashed, "/orders", ""));
+ }
+ }
+
+ @Nested
+ @DisplayName("streamed request body byte cap")
+ class ByteCap {
+
+ @Test
+ @DisplayName("forwards each chunk immediately while the running count stays within the cap")
+ void streamsChunksUnderCap() {
+ // Arrange
+ TestReadStream source = new TestReadStream();
+ List forwarded = new ArrayList<>();
+ AtomicReference failure = new AtomicReference<>();
+ AtomicBoolean aborted = new AtomicBoolean();
+ DispatchStage.ByteCappedBodyStream capped =
+ new DispatchStage.ByteCappedBodyStream(source, 10L, () -> aborted.set(true));
+ capped.handler(forwarded::add);
+ capped.exceptionHandler(failure::set);
+
+ // Act — 5 + 4 = 9 bytes, both under the 10-byte cap
+ source.emit(Buffer.buffer("12345"));
+ assertEquals(1, forwarded.size(), "first chunk must be forwarded immediately, not buffered");
+ source.emit(Buffer.buffer("6789"));
+
+ // Assert
+ assertEquals(2, forwarded.size(), "each in-cap chunk streams through as it arrives");
+ assertNull(failure.get(), "no failure while under the cap");
+ assertFalse(aborted.get(), "the upstream call is not aborted while under the cap");
+ }
+
+ @Test
+ @DisplayName("aborts the in-flight upstream call and fails the stream when the cap is breached")
+ void abortsOnBreach() {
+ // Arrange
+ TestReadStream source = new TestReadStream();
+ List forwarded = new ArrayList<>();
+ AtomicReference failure = new AtomicReference<>();
+ AtomicBoolean aborted = new AtomicBoolean();
+ DispatchStage.ByteCappedBodyStream capped =
+ new DispatchStage.ByteCappedBodyStream(source, 10L, () -> aborted.set(true));
+ capped.handler(forwarded::add);
+ capped.exceptionHandler(failure::set);
+
+ // Act — 6 bytes (ok), then 6 more crossing the 10-byte cap
+ source.emit(Buffer.buffer("123456"));
+ source.emit(Buffer.buffer("ABCDEF"));
+
+ // Assert — the breaching chunk is not forwarded, the call is aborted, and a 400 is raised
+ assertEquals(1, forwarded.size(), "the breaching chunk must never cross to the upstream");
+ assertTrue(aborted.get(), "a mid-stream breach must abort the in-flight upstream call");
+ Throwable raised = failure.get();
+ GatewayException gatewayException = assertInstanceOf(GatewayException.class, raised);
+ assertEquals(EventType.PARAMETER_LIMIT_EXCEEDED, gatewayException.getEventType());
+ }
+
+ @Test
+ @DisplayName("ignores further chunks once the stream has aborted")
+ void ignoresChunksAfterAbort() {
+ // Arrange
+ TestReadStream source = new TestReadStream();
+ List forwarded = new ArrayList<>();
+ DispatchStage.ByteCappedBodyStream capped =
+ new DispatchStage.ByteCappedBodyStream(source, 4L, () -> {
+ });
+ capped.handler(forwarded::add);
+ capped.exceptionHandler(t -> {
+ });
+
+ // Act
+ source.emit(Buffer.buffer("12345")); // 5 bytes → immediate breach
+ source.emit(Buffer.buffer("late")); // must be ignored
+
+ // Assert
+ assertTrue(forwarded.isEmpty(), "no chunk crosses once the very first breaches the cap");
+ }
+ }
+
+ @Nested
+ @DisplayName("stream-aware retry gating on the guarded dispatch path")
+ class RetryGating {
+
+ private DispatchStage newStage() {
+ return new DispatchStage(1024L, new UpstreamFailureMapper(new GatewayEventCounter()));
+ }
+
+ /** A retry-enabled guard (1 + 2 retries), aborting on {@link GatewayException} like production. */
+ private Guard retryGuard() {
+ return Guard.create()
+ .withRetry().maxRetries(2).delay(0, ChronoUnit.MILLIS)
+ .abortOn(GatewayException.class).done()
+ .build();
+ }
+
+ @Test
+ @DisplayName("retries an idempotent GET while no request body byte has been sent")
+ void retriesIdempotentWithoutBody() {
+ // Arrange — a failing upstream that never streams a body byte (fails before send)
+ DispatchStage stage = newStage();
+ StreamAwareRetryGate gate = new StreamAwareRetryGate(true);
+ AtomicInteger attempts = new AtomicInteger();
+ AtomicLong bytesSent = new AtomicLong();
+ Callable failing = () -> {
+ attempts.incrementAndGet();
+ // awaitDispatch surfaces upstream failures as ExecutionException (from Future#get);
+ // guardedDispatch must unwrap it before mapping/retrying.
+ throw new ExecutionException("upstream down", new IllegalStateException("upstream down"));
+ };
+
+ // Act — the stream is never subscribed (failure before send), so retry is allowed
+ GatewayException raised = assertThrows(GatewayException.class,
+ () -> stage.guardedDispatch(retryGuard(), gate, HttpMethod.GET, bytesSent::get, () -> false,
+ failing));
+
+ // Assert — the safe idempotent+bodyless request was retried the full budget
+ assertEquals(3, attempts.get(),
+ "an idempotent GET with zero body bytes must be retried (1 attempt + 2 retries)");
+ assertEquals(EventType.UPSTREAM_ERROR, raised.getEventType());
+ }
+
+ @Test
+ @DisplayName("never retries a non-idempotent POST — the retry re-entry is aborted")
+ void neverRetriesPost() {
+ // Arrange
+ DispatchStage stage = newStage();
+ StreamAwareRetryGate gate = new StreamAwareRetryGate(true);
+ AtomicInteger attempts = new AtomicInteger();
+ AtomicLong bytesSent = new AtomicLong();
+ Callable failing = () -> {
+ attempts.incrementAndGet();
+ throw new ExecutionException("upstream down", new IllegalStateException("upstream down"));
+ };
+
+ // Act
+ assertThrows(GatewayException.class,
+ () -> stage.guardedDispatch(retryGuard(), gate, HttpMethod.POST, bytesSent::get, () -> false,
+ failing));
+
+ // Assert — a POST must never be re-sent, so the upstream is called exactly once
+ assertEquals(1, attempts.get(), "a POST must never be re-sent to the upstream");
+ }
+
+ @Test
+ @DisplayName("never retries an idempotent request once a body byte has crossed to the upstream")
+ void neverRetriesAfterBodyByte() {
+ // Arrange — the first attempt streams body bytes before it fails mid-body
+ DispatchStage stage = newStage();
+ StreamAwareRetryGate gate = new StreamAwareRetryGate(true);
+ AtomicInteger attempts = new AtomicInteger();
+ AtomicLong bytesSent = new AtomicLong();
+ Callable failing = () -> {
+ attempts.incrementAndGet();
+ bytesSent.addAndGet(5L);
+ throw new ExecutionException("upstream down mid-body",
+ new IllegalStateException("upstream down mid-body"));
+ };
+
+ // Act
+ assertThrows(GatewayException.class,
+ () -> stage.guardedDispatch(retryGuard(), gate, HttpMethod.PUT, bytesSent::get, () -> false,
+ failing));
+
+ // Assert — a streamed request cannot be replayed once a body byte has been sent
+ assertEquals(1, attempts.get(),
+ "an idempotent request whose body has streamed a byte must never be retried");
+ }
+
+ @Test
+ @DisplayName("never retries when the one-shot body stream was already subscribed, even with zero bytes sent")
+ void neverRetriesWhenBodyStreamAlreadySubscribed() {
+ // Arrange — the first attempt reached request.send(...) and subscribed the single-use
+ // request-body stream before failing, even though no body byte was counted yet. A retry
+ // would re-attach the already-consumed stream and stall, so it must fail explicitly.
+ DispatchStage stage = newStage();
+ StreamAwareRetryGate gate = new StreamAwareRetryGate(true);
+ AtomicInteger attempts = new AtomicInteger();
+ AtomicLong bytesSent = new AtomicLong();
+ Callable failing = () -> {
+ attempts.incrementAndGet();
+ throw new ExecutionException("upstream reset after subscribe",
+ new IllegalStateException("upstream down"));
+ };
+
+ // Act — idempotent GET, zero bytes sent, but the body stream is already subscribed
+ assertThrows(GatewayException.class,
+ () -> stage.guardedDispatch(retryGuard(), gate, HttpMethod.GET, bytesSent::get, () -> true,
+ failing));
+
+ // Assert — reusing an already-subscribed one-shot body stream is refused, no re-send
+ assertEquals(1, attempts.get(),
+ "a retry that would reuse an already-subscribed one-shot body stream must be refused");
+ }
+ }
+
+ /**
+ * A minimal {@link ReadStream} fake: captures the handler the decorator installs and lets a test
+ * push buffers synchronously through it.
+ */
+ private static final class TestReadStream implements ReadStream {
+
+ private @Nullable Handler handler;
+ private @Nullable Handler exceptionHandler;
+ private @Nullable Handler endHandler;
+
+ void emit(Buffer buffer) {
+ if (handler != null) {
+ handler.handle(buffer);
+ }
+ }
+
+ @Override
+ public ReadStream handler(@Nullable Handler handler) {
+ this.handler = handler;
+ return this;
+ }
+
+ @Override
+ public ReadStream exceptionHandler(@Nullable Handler exceptionHandler) {
+ this.exceptionHandler = exceptionHandler;
+ return this;
+ }
+
+ @Override
+ public ReadStream pause() {
+ return this;
+ }
+
+ @Override
+ public ReadStream resume() {
+ return this;
+ }
+
+ @Override
+ public ReadStream fetch(long amount) {
+ return this;
+ }
+
+ @Override
+ public ReadStream endHandler(@Nullable Handler endHandler) {
+ this.endHandler = endHandler;
+ return this;
+ }
+ }
+}
diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/api/edge/EdgeHardeningTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/api/edge/EdgeHardeningTest.java
new file mode 100644
index 0000000..6c80903
--- /dev/null
+++ b/api-sheriff/src/test/java/de/cuioss/sheriff/api/edge/EdgeHardeningTest.java
@@ -0,0 +1,127 @@
+/*
+ * 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.edge;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.concurrent.TimeUnit;
+
+import io.vertx.core.http.HttpServerOptions;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+@DisplayName("EdgeHardeningOptions — inbound transport bounds and admission/drain limits")
+class EdgeHardeningTest {
+
+ private static final int MAX_HEADER_SIZE_BYTES = 16 * 1024;
+ private static final int MAX_INITIAL_LINE_LENGTH_BYTES = 8 * 1024;
+ private static final int MAX_CHUNK_SIZE_BYTES = 16 * 1024;
+ private static final int IDLE_TIMEOUT_SECONDS = 60;
+ private static final int ADMISSION_CAP = 2048;
+ private static final long DRAIN_TIMEOUT_MILLIS = 25_000L;
+
+ @Test
+ @DisplayName("clamps the plaintext listener framing to bounded header, line, and chunk sizes")
+ void clampsPlaintextListenerFraming() {
+ // Arrange
+ EdgeHardeningOptions hardening = new EdgeHardeningOptions();
+ HttpServerOptions options = new HttpServerOptions();
+
+ // Act
+ hardening.customizeHttpServer(options);
+
+ // Assert — an over-long line, over-large header block, or over-large chunk is rejected by
+ // the Vert.x codec before a request is ever admitted to the pipeline.
+ assertEquals(MAX_HEADER_SIZE_BYTES, options.getMaxHeaderSize(),
+ "The whole request header block is bounded to 16 KiB");
+ assertEquals(MAX_INITIAL_LINE_LENGTH_BYTES, options.getMaxInitialLineLength(),
+ "The HTTP/1.x request line is bounded to 8 KiB");
+ assertEquals(MAX_CHUNK_SIZE_BYTES, options.getMaxChunkSize(),
+ "A single chunked-transfer chunk is bounded to 16 KiB");
+ }
+
+ @Test
+ @DisplayName("reaps an idle connection after a bounded idle window (slow-loris / h2 abuse guard)")
+ void reapsIdleConnection() {
+ // Arrange
+ EdgeHardeningOptions hardening = new EdgeHardeningOptions();
+ HttpServerOptions options = new HttpServerOptions();
+
+ // Act
+ hardening.customizeHttpServer(options);
+
+ // Assert — an idle connection is reaped so a slow-loris / h2 abuse load cannot pin slots.
+ assertEquals(IDLE_TIMEOUT_SECONDS, options.getIdleTimeout(),
+ "An idle connection is reaped after 60 seconds");
+ assertEquals(TimeUnit.SECONDS, options.getIdleTimeoutUnit(),
+ "The idle-timeout unit is seconds");
+ }
+
+ @Test
+ @DisplayName("applies the same transport bounds to the TLS listener as to the plaintext listener")
+ void appliesSameBoundsToHttpsListener() {
+ // Arrange
+ EdgeHardeningOptions hardening = new EdgeHardeningOptions();
+ HttpServerOptions options = new HttpServerOptions();
+
+ // Act
+ hardening.customizeHttpsServer(options);
+
+ // Assert — the customizer applies to every listener Quarkus opens, TLS included.
+ assertEquals(MAX_HEADER_SIZE_BYTES, options.getMaxHeaderSize(),
+ "The TLS listener bounds the header block identically");
+ assertEquals(MAX_INITIAL_LINE_LENGTH_BYTES, options.getMaxInitialLineLength(),
+ "The TLS listener bounds the request line identically");
+ assertEquals(MAX_CHUNK_SIZE_BYTES, options.getMaxChunkSize(),
+ "The TLS listener bounds the chunk size identically");
+ assertEquals(IDLE_TIMEOUT_SECONDS, options.getIdleTimeout(),
+ "The TLS listener reaps idle connections identically");
+ assertEquals(TimeUnit.SECONDS, options.getIdleTimeoutUnit(),
+ "The TLS listener idle-timeout unit is seconds");
+ }
+
+ @Test
+ @DisplayName("bounds in-flight requests with a positive admission cap")
+ void exposesPositiveAdmissionCap() {
+ // Arrange
+ EdgeHardeningOptions hardening = new EdgeHardeningOptions();
+
+ // Act
+ int cap = hardening.admissionCap();
+
+ // Assert — a request beyond the cap is rejected 503 before a virtual thread is dispatched,
+ // so a flood cannot spawn unbounded virtual threads.
+ assertEquals(ADMISSION_CAP, cap, "The admission cap is the documented secure default");
+ assertTrue(cap > 0, "The admission cap must be a positive bound");
+ }
+
+ @Test
+ @DisplayName("bounds the graceful-drain wait below the Quarkus shutdown window")
+ void exposesBoundedDrainTimeout() {
+ // Arrange
+ EdgeHardeningOptions hardening = new EdgeHardeningOptions();
+
+ // Act
+ long drainMillis = hardening.drainTimeoutMillis();
+
+ // Assert — the drain timeout is a positive, bounded wait kept below the Quarkus default
+ // 30-second shutdown timeout so the drain completes within the shutdown window.
+ assertEquals(DRAIN_TIMEOUT_MILLIS, drainMillis, "The drain timeout is the documented secure default");
+ assertTrue(drainMillis > 0L, "The drain timeout must be a positive bound");
+ assertTrue(drainMillis < 30_000L, "The drain timeout must stay below the 30s Quarkus shutdown window");
+ }
+}
diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/api/edge/GatewayEdgePipelineTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/api/edge/GatewayEdgePipelineTest.java
new file mode 100644
index 0000000..3e65a18
--- /dev/null
+++ b/api-sheriff/src/test/java/de/cuioss/sheriff/api/edge/GatewayEdgePipelineTest.java
@@ -0,0 +1,347 @@
+/*
+ * 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.edge;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.lang.annotation.Annotation;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.StringJoiner;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+
+
+import de.cuioss.http.security.config.SecurityConfiguration;
+import de.cuioss.sheriff.api.config.model.AuthConfig;
+import de.cuioss.sheriff.api.config.model.GatewayConfig;
+import de.cuioss.sheriff.api.config.model.HttpMethod;
+import de.cuioss.sheriff.api.config.model.MatchConfig;
+import de.cuioss.sheriff.api.config.model.Protocol;
+import de.cuioss.sheriff.api.config.model.ResolvedRoute;
+import de.cuioss.sheriff.api.config.model.ResolvedUpstream;
+import de.cuioss.sheriff.api.config.model.RouteTable;
+import de.cuioss.sheriff.api.config.model.SecurityHeadersConfig;
+import de.cuioss.sheriff.api.quarkus.SheriffMetrics;
+import de.cuioss.sheriff.token.validation.TokenValidator;
+import de.cuioss.sheriff.token.validation.test.generator.TestTokenGenerators;
+import de.cuioss.test.generator.junit.EnableGeneratorController;
+
+import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
+import io.vertx.core.Vertx;
+import io.vertx.core.buffer.Buffer;
+import io.vertx.core.http.HttpClient;
+import io.vertx.core.http.HttpServer;
+import io.vertx.core.http.RequestOptions;
+import io.vertx.ext.web.Router;
+import jakarta.enterprise.inject.Instance;
+import jakarta.enterprise.util.TypeLiteral;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+/**
+ * End-to-end serving contract of the public data-plane edge, driven over a live Vert.x HTTP server
+ * against a local stub upstream — no Docker, no Quarkus. A real request crosses the whole fixed
+ * pipeline (stages 0-7): response-header prep + CORS preflight, the baseline security filter, route
+ * selection and the verb gate, the per-route thorough checks, bearer authentication, streamed
+ * upstream dispatch, and the streamed response relay. These complement the boot-time and lifecycle
+ * assertions in {@link GatewayEdgeRouteTest}.
+ */
+@EnableGeneratorController
+@DisplayName("GatewayEdgeRoute — end-to-end request pipeline over a live Vert.x server")
+class GatewayEdgePipelineTest {
+
+ private static final String ORIGIN = "https://app.example";
+
+ private Vertx vertx;
+ private ExecutorService virtualThreadExecutor;
+ private HttpServer upstreamServer;
+ private HttpServer frontServer;
+ private HttpClient client;
+ private int frontPort;
+
+ @BeforeEach
+ void setUp() throws Exception {
+ vertx = Vertx.vertx();
+ virtualThreadExecutor = Executors.newVirtualThreadPerTaskExecutor();
+
+ // Stub upstream: echoes the received method, URI, and body so the forward path is observable.
+ upstreamServer = vertx.createHttpServer().requestHandler(request ->
+ request.body().onComplete(body -> {
+ String payload = body.succeeded() && body.result() != null ? body.result().toString() : "";
+ request.response()
+ .putHeader("X-Upstream-Echo", "hit")
+ .end(request.method().name() + " " + request.uri() + " body=" + payload);
+ }))
+ .listen(0).toCompletionStage().toCompletableFuture().get(15, TimeUnit.SECONDS);
+ int upstreamPort = upstreamServer.actualPort();
+
+ TokenValidator tokenValidator = TokenValidator.builder()
+ .issuerConfig(TestTokenGenerators.accessTokens().next().getIssuerConfig()).build();
+
+ RouteTable routeTable = new RouteTable(List.of(
+ route("secure", "/secure", "bearer", upstreamPort, HttpMethod.GET),
+ route("echo", "/echo", "none", upstreamPort, HttpMethod.GET, HttpMethod.POST)));
+
+ GatewayConfig gatewayConfig = GatewayConfig.builder()
+ .version(1)
+ .securityHeaders(Optional.of(corsHeaders()))
+ .build();
+
+ GatewayEdgeRoute edge = new GatewayEdgeRoute(routeTable, gatewayConfig,
+ new SingletonInstance<>(tokenValidator), vertx, virtualThreadExecutor,
+ new EdgeHardeningOptions(), new SheriffMetrics(new SimpleMeterRegistry()));
+
+ Router router = Router.router(vertx);
+ edge.registerRoutes(router);
+ frontServer = vertx.createHttpServer().requestHandler(router)
+ .listen(0).toCompletionStage().toCompletableFuture().get(15, TimeUnit.SECONDS);
+ frontPort = frontServer.actualPort();
+
+ client = vertx.createHttpClient();
+ }
+
+ @AfterEach
+ void tearDown() throws Exception {
+ client.close().toCompletionStage().toCompletableFuture().get(5, TimeUnit.SECONDS);
+ frontServer.close().toCompletionStage().toCompletableFuture().get(5, TimeUnit.SECONDS);
+ upstreamServer.close().toCompletionStage().toCompletableFuture().get(5, TimeUnit.SECONDS);
+ virtualThreadExecutor.close();
+ vertx.close().toCompletionStage().toCompletableFuture().get(5, TimeUnit.SECONDS);
+ }
+
+ @Test
+ @DisplayName("forwards a matched GET to the upstream and relays the streamed response body")
+ void forwardsHappyPathGet() throws Exception {
+ // Act
+ Response response = send(io.vertx.core.http.HttpMethod.GET, "/echo/orders", Map.of(), null);
+
+ // Assert — the prefix is stripped and the remainder is forwarded; the relay preserves the body
+ assertEquals(200, response.status());
+ assertEquals("hit", response.headers().get("X-Upstream-Echo"),
+ "the upstream response headers relay back to the client");
+ assertTrue(response.body().contains("GET /orders"),
+ "the forwarded request reaches the upstream with the prefix-stripped path: " + response.body());
+ }
+
+ @Test
+ @DisplayName("streams a POST request body through to the upstream")
+ void forwardsPostBody() throws Exception {
+ // Act
+ Response response = send(io.vertx.core.http.HttpMethod.POST, "/echo/submit", Map.of(), "hello-upstream");
+
+ // Assert
+ assertEquals(200, response.status());
+ assertTrue(response.body().contains("POST /submit"), response.body());
+ assertTrue(response.body().contains("body=hello-upstream"),
+ "the streamed request body reaches the upstream: " + response.body());
+ }
+
+ @Test
+ @DisplayName("rejects an unmatched path 404 (deny by default)")
+ void rejectsUnmatchedPath() throws Exception {
+ // Act
+ Response response = send(io.vertx.core.http.HttpMethod.GET, "/nowhere", Map.of(), null);
+
+ // Assert
+ assertEquals(404, response.status());
+ }
+
+ @Test
+ @DisplayName("rejects a verb outside the route's allowed_methods 405 with an Allow header")
+ void rejectsDisallowedVerb() throws Exception {
+ // Act — the echo route allows GET and POST, never DELETE
+ Response response = send(io.vertx.core.http.HttpMethod.DELETE, "/echo/orders", Map.of(), null);
+
+ // Assert
+ assertEquals(405, response.status());
+ assertNotNull(response.headers().get("Allow"), "a 405 names the permitted verbs in the Allow header");
+ }
+
+ @Test
+ @DisplayName("rejects a request breaching the baseline parameter-count cap 400")
+ void rejectsExcessiveParameters() throws Exception {
+ // Arrange — one parameter beyond the default cap trips the stage-1 baseline filter
+ int cap = SecurityConfiguration.defaults().maxParameterCount();
+ StringJoiner query = new StringJoiner("&", "/echo/orders?", "");
+ for (int i = 0; i <= cap; i++) {
+ query.add("p" + i + "=1");
+ }
+
+ // Act
+ Response response = send(io.vertx.core.http.HttpMethod.GET, query.toString(), Map.of(), null);
+
+ // Assert
+ assertEquals(400, response.status());
+ }
+
+ @Test
+ @DisplayName("rejects a bearer route without a token 401 carrying WWW-Authenticate")
+ void rejectsMissingBearerToken() throws Exception {
+ // Act
+ Response response = send(io.vertx.core.http.HttpMethod.GET, "/secure/data", Map.of(), null);
+
+ // Assert
+ assertEquals(401, response.status());
+ assertEquals("Bearer", response.headers().get("WWW-Authenticate"),
+ "a missing bearer token challenges with WWW-Authenticate: Bearer");
+ }
+
+ @Test
+ @DisplayName("rejects a bearer route with an invalid token 401")
+ void rejectsInvalidBearerToken() throws Exception {
+ // Act — a syntactically-bogus token is rejected offline by the validator
+ Response response = send(io.vertx.core.http.HttpMethod.GET, "/secure/data",
+ Map.of("Authorization", "Bearer not-a-real-token"), null);
+
+ // Assert
+ assertEquals(401, response.status());
+ }
+
+ @Test
+ @DisplayName("answers an allow-listed CORS preflight 204 at stage 0 without reaching the upstream")
+ void answersCorsPreflight() throws Exception {
+ // Act — an OPTIONS preflight from an allow-listed origin short-circuits before route selection
+ Response response = send(io.vertx.core.http.HttpMethod.OPTIONS, "/echo/orders",
+ Map.of("Origin", ORIGIN, "Access-Control-Request-Method", "GET"), null);
+
+ // Assert
+ assertEquals(204, response.status());
+ assertEquals(ORIGIN, response.headers().get("Access-Control-Allow-Origin"),
+ "the preflight reflects the allow-listed origin");
+ assertNotNull(response.headers().get("Access-Control-Allow-Methods"),
+ "the preflight advertises the allowed methods");
+ }
+
+ private Response send(io.vertx.core.http.HttpMethod method, String uri, Map requestHeaders,
+ String body) throws Exception {
+ RequestOptions options = new RequestOptions()
+ .setHost("localhost").setPort(frontPort).setMethod(method).setURI(uri);
+ CompletableFuture future = client.request(options)
+ .compose(request -> {
+ requestHeaders.forEach(request::putHeader);
+ return body == null ? request.send() : request.send(Buffer.buffer(body));
+ })
+ .compose(response -> {
+ int status = response.statusCode();
+ Map headers = new HashMap<>();
+ response.headers().forEach(entry -> headers.putIfAbsent(entry.getKey(), entry.getValue()));
+ return response.body().map(buffer -> new Response(status, headers,
+ buffer == null ? "" : buffer.toString()));
+ })
+ .toCompletionStage().toCompletableFuture();
+ return future.get(15, TimeUnit.SECONDS);
+ }
+
+ private static SecurityHeadersConfig corsHeaders() {
+ SecurityHeadersConfig.Cors cors = SecurityHeadersConfig.Cors.builder()
+ .enabled(Optional.of(Boolean.TRUE))
+ .allowedOrigins(List.of(ORIGIN))
+ .allowedMethods(List.of("GET", "POST"))
+ .allowedHeaders(List.of("authorization"))
+ .build();
+ return SecurityHeadersConfig.builder().cors(Optional.of(cors)).build();
+ }
+
+ private static ResolvedRoute route(String id, String pathPrefix, String require, int upstreamPort,
+ HttpMethod... methods) {
+ return ResolvedRoute.builder()
+ .id(id)
+ .protocol(Protocol.HTTP)
+ .match(MatchConfig.builder().pathPrefix(pathPrefix).build())
+ .effectiveAuth(AuthConfig.builder().require(require).build())
+ .effectiveAllowedMethods(List.of(methods))
+ .upstream(new ResolvedUpstream("http", "localhost", upstreamPort, ""))
+ .build();
+ }
+
+ /** A record capturing the terminal response for assertions after the streamed body completes. */
+ private record Response(int status, Map headers, String body) {
+ }
+
+ /**
+ * Minimal {@link Instance} test double resolving to a single supplied validator; only
+ * {@link #get()} and {@link #iterator()} are exercised by the pipeline, the remaining CDI
+ * accessors are unused and throw.
+ */
+ private static final class SingletonInstance implements Instance {
+
+ private final T value;
+
+ SingletonInstance(T value) {
+ this.value = value;
+ }
+
+ @Override
+ public T get() {
+ return value;
+ }
+
+ @Override
+ public Instance select(Annotation... qualifiers) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public Instance select(Class subtype, Annotation... qualifiers) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public Instance select(TypeLiteral subtype, Annotation... qualifiers) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public boolean isUnsatisfied() {
+ return false;
+ }
+
+ @Override
+ public boolean isAmbiguous() {
+ return false;
+ }
+
+ @Override
+ public void destroy(T instance) {
+ // no-op: the test double owns no lifecycle
+ }
+
+ @Override
+ public Handle getHandle() {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public Iterable extends Handle> handles() {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public Iterator iterator() {
+ return List.of(value).iterator();
+ }
+ }
+}
diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/api/edge/GatewayEdgeRouteTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/api/edge/GatewayEdgeRouteTest.java
new file mode 100644
index 0000000..4ce5d13
--- /dev/null
+++ b/api-sheriff/src/test/java/de/cuioss/sheriff/api/edge/GatewayEdgeRouteTest.java
@@ -0,0 +1,255 @@
+/*
+ * 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.edge;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively;
+
+import java.lang.annotation.Annotation;
+import java.time.Duration;
+import java.util.Iterator;
+import java.util.List;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+
+import de.cuioss.sheriff.api.config.model.AuthConfig;
+import de.cuioss.sheriff.api.config.model.GatewayConfig;
+import de.cuioss.sheriff.api.config.model.HttpMethod;
+import de.cuioss.sheriff.api.config.model.MatchConfig;
+import de.cuioss.sheriff.api.config.model.Protocol;
+import de.cuioss.sheriff.api.config.model.ResolvedRoute;
+import de.cuioss.sheriff.api.config.model.ResolvedUpstream;
+import de.cuioss.sheriff.api.config.model.RouteTable;
+import de.cuioss.sheriff.api.events.EventType;
+import de.cuioss.sheriff.api.events.GatewayException;
+import de.cuioss.sheriff.api.quarkus.SheriffMetrics;
+import de.cuioss.sheriff.token.validation.TokenValidator;
+import de.cuioss.sheriff.token.validation.test.generator.TestTokenGenerators;
+import de.cuioss.test.generator.junit.EnableGeneratorController;
+
+import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
+import io.quarkus.runtime.ShutdownEvent;
+import io.vertx.core.Vertx;
+import io.vertx.ext.web.Router;
+import jakarta.enterprise.inject.Instance;
+import jakarta.enterprise.util.TypeLiteral;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Boot-time and lifecycle contract of the public data-plane edge. The per-request serving behaviour
+ * (pipeline stages over a live Vert.x server, h2 abuse bounds, streamed relay on the public port) is
+ * exercised end-to-end by the {@code integration-tests} module; these module tests cover the
+ * deterministic, server-free guarantees: clean boot assembly, fail-fast on an invalid route set,
+ * the catch-all registered last, and a bounded graceful drain.
+ */
+@EnableGeneratorController
+@DisplayName("GatewayEdgeRoute — boot-time assembly, catch-all registration, and graceful drain")
+class GatewayEdgeRouteTest {
+
+ private Vertx vertx;
+ private ExecutorService virtualThreadExecutor;
+ private GatewayConfig gatewayConfig;
+ private TokenValidator tokenValidator;
+ private EdgeHardeningOptions hardening;
+
+ @BeforeEach
+ void setUp() {
+ vertx = Vertx.vertx();
+ virtualThreadExecutor = Executors.newVirtualThreadPerTaskExecutor();
+ gatewayConfig = GatewayConfig.builder().version(1).build();
+ tokenValidator = TokenValidator.builder()
+ .issuerConfig(TestTokenGenerators.accessTokens().next().getIssuerConfig()).build();
+ hardening = new EdgeHardeningOptions();
+ }
+
+ @AfterEach
+ void tearDown() {
+ virtualThreadExecutor.close();
+ vertx.close();
+ }
+
+ @Test
+ @DisplayName("boots cleanly over an empty route table")
+ void bootsCleanlyOverEmptyRouteTable() {
+ // Arrange
+ RouteTable emptyTable = new RouteTable(List.of());
+
+ // Act + Assert — assembling every stage once, at boot, must not throw for a valid config.
+ assertDoesNotThrow(() -> newEdge(emptyTable),
+ "A valid route set assembles every stage without error");
+ }
+
+ @Test
+ @DisplayName("registers the catch-all data-plane route so management routes keep priority")
+ void registersCatchAllRoute() {
+ // Arrange
+ GatewayEdgeRoute edge = newEdge(new RouteTable(List.of()));
+ Router router = Router.router(vertx);
+
+ // Act
+ edge.registerRoutes(router);
+
+ // Assert — exactly one catch-all route is registered; it is added last so management /
+ // health routes registered earlier keep priority.
+ assertEquals(1, router.getRoutes().size(), "The edge registers a single catch-all route");
+ }
+
+ @Test
+ @DisplayName("fails boot fast when a route requires session authentication")
+ void failsBootForSessionAuth() {
+ // Arrange
+ RouteTable sessionTable = new RouteTable(List.of(
+ route("s", Protocol.HTTP, "session")));
+
+ // Act
+ GatewayException thrown = assertThrows(GatewayException.class, () -> newEdge(sessionTable),
+ "Session auth is not implemented and must fail boot");
+
+ // Assert
+ assertEquals(EventType.CONFIG_INVALID, thrown.getEventType(),
+ "A session-auth route is rejected as an invalid configuration");
+ }
+
+ @Test
+ @DisplayName("fails boot fast for a gRPC route (unsupported protocol)")
+ void failsBootForGrpcProtocol() {
+ // Arrange
+ RouteTable grpcTable = new RouteTable(List.of(
+ route("g", Protocol.GRPC, "none")));
+
+ // Act
+ GatewayException thrown = assertThrows(GatewayException.class, () -> newEdge(grpcTable),
+ "gRPC is unsupported and must fail boot");
+
+ // Assert
+ assertEquals(EventType.CONFIG_INVALID, thrown.getEventType(),
+ "A gRPC route is rejected as an invalid configuration");
+ }
+
+ @Test
+ @DisplayName("fails boot fast for a WebSocket route (unsupported protocol)")
+ void failsBootForWebSocketProtocol() {
+ // Arrange
+ RouteTable webSocketTable = new RouteTable(List.of(
+ route("w", Protocol.WEBSOCKET, "none")));
+
+ // Act
+ GatewayException thrown = assertThrows(GatewayException.class, () -> newEdge(webSocketTable),
+ "WebSocket is unsupported and must fail boot");
+
+ // Assert
+ assertEquals(EventType.CONFIG_INVALID, thrown.getEventType(),
+ "A WebSocket route is rejected as an invalid configuration");
+ }
+
+ @Test
+ @DisplayName("drains within the bounded window on shutdown when nothing is in flight")
+ void drainsPromptlyWhenIdle() {
+ // Arrange
+ GatewayEdgeRoute edge = newEdge(new RouteTable(List.of()));
+
+ // Act + Assert — with zero in-flight requests the drain loop returns immediately, well
+ // within its bounded window, so the shutdown completes cleanly and never hangs.
+ assertTimeoutPreemptively(Duration.ofSeconds(5), () -> edge.onShutdown(new ShutdownEvent()),
+ "Graceful drain returns promptly when no request is in flight");
+ }
+
+ private GatewayEdgeRoute newEdge(RouteTable table) {
+ return new GatewayEdgeRoute(table, gatewayConfig, new SingletonInstance<>(tokenValidator), vertx,
+ virtualThreadExecutor, hardening, new SheriffMetrics(new SimpleMeterRegistry()));
+ }
+
+ /**
+ * Minimal {@link Instance} test double resolving to a single supplied bean. These boot / drain
+ * tests exercise only {@link #get()} (and none of them reaches a {@code require: bearer} route, so
+ * even that is not resolved); the remaining CDI accessors are unused and throw.
+ */
+ private static final class SingletonInstance implements Instance {
+
+ private final T value;
+
+ SingletonInstance(T value) {
+ this.value = value;
+ }
+
+ @Override
+ public T get() {
+ return value;
+ }
+
+ @Override
+ public Instance select(Annotation... qualifiers) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public Instance select(Class subtype, Annotation... qualifiers) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public Instance select(TypeLiteral subtype, Annotation... qualifiers) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public boolean isUnsatisfied() {
+ return false;
+ }
+
+ @Override
+ public boolean isAmbiguous() {
+ return false;
+ }
+
+ @Override
+ public void destroy(T instance) {
+ // no-op: the test double owns no lifecycle
+ }
+
+ @Override
+ public Handle getHandle() {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public Iterable extends Handle> handles() {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public Iterator iterator() {
+ return List.of(value).iterator();
+ }
+ }
+
+ private static ResolvedRoute route(String id, Protocol protocol, String require) {
+ return ResolvedRoute.builder()
+ .id(id)
+ .protocol(protocol)
+ .match(MatchConfig.builder().pathPrefix("/" + id).build())
+ .effectiveAuth(AuthConfig.builder().require(require).build())
+ .effectiveAllowedMethods(List.of(HttpMethod.GET))
+ .upstream(new ResolvedUpstream("https", id + ".example", 443, ""))
+ .build();
+ }
+}
diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/api/edge/ResponseStageTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/api/edge/ResponseStageTest.java
new file mode 100644
index 0000000..ea775d4
--- /dev/null
+++ b/api-sheriff/src/test/java/de/cuioss/sheriff/api/edge/ResponseStageTest.java
@@ -0,0 +1,108 @@
+/*
+ * 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.edge;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+@DisplayName("ResponseStage — stage 7 streamed response header policy")
+class ResponseStageTest {
+
+ @Nested
+ @DisplayName("hop-by-hop stripping")
+ class HopByHop {
+
+ @ParameterizedTest
+ @ValueSource(strings = {
+ "Connection", "Keep-Alive", "Proxy-Authenticate", "Proxy-Authorization",
+ "TE", "Trailer", "Transfer-Encoding", "Upgrade", "Content-Length"})
+ @DisplayName("strips hop-by-hop and framing headers regardless of not_modified")
+ void stripsHopByHop(String header) {
+ assertFalse(ResponseStage.isForwardableResponseHeader(header, true),
+ header + " is hop-by-hop and must never relay (not_modified enabled)");
+ assertFalse(ResponseStage.isForwardableResponseHeader(header, false),
+ header + " is hop-by-hop and must never relay (not_modified disabled)");
+ }
+
+ @Test
+ @DisplayName("matching is case-insensitive")
+ void caseInsensitive() {
+ assertFalse(ResponseStage.isForwardableResponseHeader("transfer-encoding", true));
+ assertFalse(ResponseStage.isForwardableResponseHeader("CONTENT-LENGTH", true));
+ }
+ }
+
+ @Nested
+ @DisplayName("conditional-response headers gated by not_modified")
+ class ConditionalHeaders {
+
+ @ParameterizedTest
+ @ValueSource(strings = {"ETag", "Last-Modified"})
+ @DisplayName("relays validators untouched when the route enables not_modified")
+ void relaysValidatorsWhenEnabled(String header) {
+ assertTrue(ResponseStage.isForwardableResponseHeader(header, true),
+ header + " must relay on a not_modified-enabled route (304 pass-through)");
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"ETag", "Last-Modified"})
+ @DisplayName("strips validators when the route disables not_modified")
+ void stripsValidatorsWhenDisabled(String header) {
+ assertFalse(ResponseStage.isForwardableResponseHeader(header, false),
+ header + " must be stripped on a not_modified-disabled route");
+ }
+ }
+
+ @Nested
+ @DisplayName("ordinary headers")
+ class OrdinaryHeaders {
+
+ @ParameterizedTest
+ @ValueSource(strings = {"Content-Type", "Cache-Control", "Set-Cookie", "Location"})
+ @DisplayName("relays ordinary response headers regardless of not_modified")
+ void relaysOrdinaryHeaders(String header) {
+ assertTrue(ResponseStage.isForwardableResponseHeader(header, true));
+ assertTrue(ResponseStage.isForwardableResponseHeader(header, false));
+ }
+ }
+
+ @Nested
+ @DisplayName("body-framing eligibility (Content-Length preservation vs chunked streaming)")
+ class BodyFraming {
+
+ @ParameterizedTest
+ @ValueSource(ints = {200, 201, 301, 400, 404, 500, 502})
+ @DisplayName("a body-bearing status streams a relayed body (chunked when length is unknown)")
+ void statusMayCarryBody(int status) {
+ assertTrue(ResponseStage.mayCarryBody(status),
+ status + " permits a message body and must frame the streamed relay");
+ }
+
+ @ParameterizedTest
+ @ValueSource(ints = {100, 101, 199, 204, 304})
+ @DisplayName("a bodyless status never frames a streamed body")
+ void statusForbidsBody(int status) {
+ assertFalse(ResponseStage.mayCarryBody(status),
+ status + " forbids a message body (1xx / 204 / 304) and must not be chunked");
+ }
+ }
+}
diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/api/edge/RouteRuntimeAssemblerTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/api/edge/RouteRuntimeAssemblerTest.java
new file mode 100644
index 0000000..0f22496
--- /dev/null
+++ b/api-sheriff/src/test/java/de/cuioss/sheriff/api/edge/RouteRuntimeAssemblerTest.java
@@ -0,0 +1,249 @@
+/*
+ * 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.edge;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotSame;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.Callable;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.Supplier;
+
+
+import de.cuioss.http.security.config.SecurityConfiguration;
+import de.cuioss.sheriff.api.config.model.AuthConfig;
+import de.cuioss.sheriff.api.config.model.ForwardConfig;
+import de.cuioss.sheriff.api.config.model.HttpMethod;
+import de.cuioss.sheriff.api.config.model.MatchConfig;
+import de.cuioss.sheriff.api.config.model.Protocol;
+import de.cuioss.sheriff.api.config.model.ResolvedRoute;
+import de.cuioss.sheriff.api.config.model.ResolvedUpstream;
+import de.cuioss.sheriff.api.config.model.RouteTable;
+import de.cuioss.sheriff.api.config.model.SecurityFilterConfig;
+import de.cuioss.sheriff.api.events.EventType;
+import de.cuioss.sheriff.api.events.GatewayException;
+import de.cuioss.sheriff.api.routing.ProtocolProcessorRegistry;
+import de.cuioss.sheriff.api.routing.RouteRuntime;
+
+import io.smallrye.faulttolerance.api.Guard;
+import io.vertx.core.Vertx;
+import jakarta.enterprise.util.TypeLiteral;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+@DisplayName("RouteRuntimeAssembler — boot-time assembly and heavy-object dedup")
+class RouteRuntimeAssemblerTest {
+
+ private Vertx vertx;
+ private RouteRuntimeAssembler assembler;
+ private RouteRuntimeAssembler.SecurityConfigurationFactory securityConfigFactory;
+ private RouteRuntimeAssembler.UpstreamClientFactory clientFactory;
+ private RouteRuntimeAssembler.ResilienceGuardFactory guardFactory;
+
+ @BeforeEach
+ void setUp() {
+ vertx = Vertx.vertx();
+ assembler = new RouteRuntimeAssembler(new ProtocolProcessorRegistry());
+ securityConfigFactory = filter -> SecurityConfiguration.builder().build();
+ clientFactory = target -> vertx.createHttpClient();
+ guardFactory = shape -> new StoredOnlyGuard();
+ }
+
+ @AfterEach
+ void tearDown() {
+ vertx.close();
+ }
+
+ @Test
+ @DisplayName("Should reuse one SecurityConfiguration for routes sharing a security-filter shape")
+ void shouldReuseSecurityConfigurationForSharedShape() {
+ SecurityFilterConfig sharedFilter = SecurityFilterConfig.builder().allowedPaths(List.of("/shared")).build();
+ var invocations = new AtomicInteger();
+ securityConfigFactory = filter -> {
+ invocations.incrementAndGet();
+ return SecurityConfiguration.builder().build();
+ };
+ RouteTable table = new RouteTable(List.of(
+ route("r1", Protocol.HTTP, "none", Optional.of(sharedFilter), upstream("a.example")),
+ route("r2", Protocol.HTTP, "none", Optional.of(sharedFilter), upstream("a.example"))));
+
+ List runtimes = assembler.assemble(table, securityConfigFactory, clientFactory, guardFactory);
+
+ assertEquals(1, invocations.get(), "The factory runs once for the shared filter shape");
+ assertSame(runtimes.getFirst().getSecurityConfiguration().orElseThrow(),
+ runtimes.get(1).getSecurityConfiguration().orElseThrow(),
+ "Both routes hold the same SecurityConfiguration reference");
+ }
+
+ @Test
+ @DisplayName("Should build distinct SecurityConfigurations for different security-filter shapes")
+ void shouldBuildDistinctSecurityConfigurationsForDifferentShapes() {
+ RouteTable table = new RouteTable(List.of(
+ route("r1", Protocol.HTTP, "none",
+ Optional.of(SecurityFilterConfig.builder().allowedPaths(List.of("/a")).build()), upstream("a.example")),
+ route("r2", Protocol.HTTP, "none",
+ Optional.of(SecurityFilterConfig.builder().allowedPaths(List.of("/b")).build()), upstream("a.example"))));
+
+ List runtimes = assembler.assemble(table, securityConfigFactory, clientFactory, guardFactory);
+
+ assertNotSame(runtimes.getFirst().getSecurityConfiguration().orElseThrow(),
+ runtimes.get(1).getSecurityConfiguration().orElseThrow(),
+ "Distinct filter shapes must not share a SecurityConfiguration");
+ }
+
+ @Test
+ @DisplayName("Should reuse one client for routes sharing an upstream tuple and split by tuple")
+ void shouldReuseClientForSharedUpstreamTuple() {
+ RouteTable table = new RouteTable(List.of(
+ route("r1", Protocol.HTTP, "none", Optional.empty(), upstream("a.example")),
+ route("r2", Protocol.HTTP, "none", Optional.empty(), upstream("a.example")),
+ route("r3", Protocol.HTTP, "none", Optional.empty(), upstream("b.example"))));
+
+ List runtimes = assembler.assemble(table, securityConfigFactory, clientFactory, guardFactory);
+
+ assertSame(runtimes.getFirst().getHttpClient(), runtimes.get(1).getHttpClient(),
+ "Routes sharing an upstream tuple reuse one client");
+ assertNotSame(runtimes.getFirst().getHttpClient(), runtimes.get(2).getHttpClient(),
+ "A different upstream tuple gets a distinct client");
+ }
+
+ @Test
+ @DisplayName("Should preserve the route-table order")
+ void shouldPreserveRouteTableOrder() {
+ RouteTable table = new RouteTable(List.of(
+ route("first", Protocol.HTTP, "none", Optional.empty(), upstream("a.example")),
+ route("second", Protocol.GRAPHQL, "bearer", Optional.empty(), upstream("b.example"))));
+
+ List runtimes = assembler.assemble(table, securityConfigFactory, clientFactory, guardFactory);
+
+ assertEquals(List.of("first", "second"), runtimes.stream().map(RouteRuntime::getId).toList(),
+ "Assembly preserves the longest-prefix-first order");
+ }
+
+ @Test
+ @DisplayName("Should fail boot for session auth and unsupported protocols")
+ void shouldFailBootForSessionAndUnsupportedProtocols() {
+ var session = assertThrows(GatewayException.class, () -> assembler.assemble(
+ new RouteTable(List.of(route("s", Protocol.HTTP, "session", Optional.empty(), upstream("a.example")))),
+ securityConfigFactory, clientFactory, guardFactory), "session auth must fail boot");
+ var grpc = assertThrows(GatewayException.class, () -> assembler.assemble(
+ new RouteTable(List.of(route("g", Protocol.GRPC, "none", Optional.empty(), upstream("a.example")))),
+ securityConfigFactory, clientFactory, guardFactory), "gRPC must fail boot");
+ var websocket = assertThrows(GatewayException.class, () -> assembler.assemble(
+ new RouteTable(List.of(route("w", Protocol.WEBSOCKET, "none", Optional.empty(), upstream("a.example")))),
+ securityConfigFactory, clientFactory, guardFactory), "WebSocket must fail boot");
+
+ assertEquals(EventType.CONFIG_INVALID, session.getEventType(), "session rejection is a config failure");
+ assertEquals(EventType.CONFIG_INVALID, grpc.getEventType(), "gRPC rejection is a config failure");
+ assertEquals(EventType.CONFIG_INVALID, websocket.getEventType(), "WebSocket rejection is a config failure");
+ }
+
+ @Test
+ @DisplayName("Should carry the required scopes from the effective auth")
+ void shouldCarryRequiredScopes() {
+ AuthConfig auth = AuthConfig.builder().require("bearer").requiredScopes(List.of("read", "write")).build();
+ RouteTable table = new RouteTable(List.of(ResolvedRoute.builder()
+ .id("scoped").protocol(Protocol.HTTP).match(MatchConfig.builder().pathPrefix("/s").build())
+ .effectiveAuth(auth).effectiveAllowedMethods(List.of(HttpMethod.GET))
+ .upstream(upstream("a.example")).build()));
+
+ List runtimes = assembler.assemble(table, securityConfigFactory, clientFactory, guardFactory);
+
+ assertTrue(runtimes.getFirst().getRequiredScopes().containsAll(List.of("read", "write")),
+ "Required scopes flow from the effective auth to the runtime");
+ }
+
+ @Test
+ @DisplayName("Should carry the effective forward allowlist from the resolved route")
+ void shouldCarryEffectiveForward() {
+ ForwardConfig forward = new ForwardConfig(List.of("Accept"), List.of("page"),
+ Map.of("X-Gateway", "api-sheriff"));
+ RouteTable table = new RouteTable(List.of(ResolvedRoute.builder()
+ .id("fwd").protocol(Protocol.HTTP).match(MatchConfig.builder().pathPrefix("/f").build())
+ .effectiveAuth(AuthConfig.builder().require("none").build())
+ .effectiveAllowedMethods(List.of(HttpMethod.GET))
+ .upstream(upstream("a.example")).effectiveForward(forward).build()));
+
+ List runtimes = assembler.assemble(table, securityConfigFactory, clientFactory, guardFactory);
+
+ assertEquals(forward, runtimes.getFirst().getEffectiveForward(),
+ "The materialized forward allowlist flows to the runtime unchanged");
+ }
+
+ @Test
+ @DisplayName("Should default an absent forward block to a deny-by-default empty allowlist")
+ void shouldDefaultAbsentForwardToEmpty() {
+ RouteTable table = new RouteTable(List.of(
+ route("r1", Protocol.HTTP, "none", Optional.empty(), upstream("a.example"))));
+
+ List runtimes = assembler.assemble(table, securityConfigFactory, clientFactory, guardFactory);
+
+ assertTrue(runtimes.getFirst().getEffectiveForward().headersAllow().isEmpty(),
+ "An unforwarded route carries an empty, deny-by-default allowlist");
+ }
+
+ private static ResolvedRoute route(String id, Protocol protocol, String require,
+ Optional filter, ResolvedUpstream upstream) {
+ return ResolvedRoute.builder()
+ .id(id)
+ .protocol(protocol)
+ .match(MatchConfig.builder().pathPrefix("/" + id).build())
+ .effectiveAuth(AuthConfig.builder().require(require).build())
+ .effectiveAllowedMethods(List.of(HttpMethod.GET))
+ .effectiveSecurityFilter(filter)
+ .upstream(upstream)
+ .build();
+ }
+
+ private static ResolvedUpstream upstream(String host) {
+ return new ResolvedUpstream("https", host, 443, "");
+ }
+
+ /**
+ * A {@link Guard} test double that is only ever stored on a {@link RouteRuntime} and never
+ * invoked during assembly, so its guard methods reject execution.
+ */
+ private static final class StoredOnlyGuard implements Guard {
+
+ @Override
+ public T call(Callable action, Class asType) {
+ throw new UnsupportedOperationException("stored-only test guard");
+ }
+
+ @Override
+ public T call(Callable action, TypeLiteral asType) {
+ throw new UnsupportedOperationException("stored-only test guard");
+ }
+
+ @Override
+ public T get(Supplier action, Class asType) {
+ throw new UnsupportedOperationException("stored-only test guard");
+ }
+
+ @Override
+ public T get(Supplier action, TypeLiteral asType) {
+ throw new UnsupportedOperationException("stored-only test guard");
+ }
+ }
+}
diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/api/edge/StreamAwareRetryGateTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/api/edge/StreamAwareRetryGateTest.java
new file mode 100644
index 0000000..b5d7a96
--- /dev/null
+++ b/api-sheriff/src/test/java/de/cuioss/sheriff/api/edge/StreamAwareRetryGateTest.java
@@ -0,0 +1,85 @@
+/*
+ * 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.edge;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+
+import de.cuioss.sheriff.api.config.model.HttpMethod;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.EnumSource;
+
+@DisplayName("StreamAwareRetryGate — stage 6 stream-aware retry gate")
+class StreamAwareRetryGateTest {
+
+ @Nested
+ @DisplayName("idempotency classification (RFC 7231 §4.2.2)")
+ class Idempotency {
+
+ @ParameterizedTest
+ @EnumSource(value = HttpMethod.class, names = {"GET", "HEAD", "PUT", "DELETE", "OPTIONS"})
+ @DisplayName("classifies safe/idempotent verbs as idempotent")
+ void idempotentVerbs(HttpMethod method) {
+ assertTrue(StreamAwareRetryGate.isIdempotent(method), method + " must be idempotent");
+ }
+
+ @ParameterizedTest
+ @EnumSource(value = HttpMethod.class, names = {"POST", "PATCH"})
+ @DisplayName("classifies non-idempotent verbs as non-idempotent")
+ void nonIdempotentVerbs(HttpMethod method) {
+ assertFalse(StreamAwareRetryGate.isIdempotent(method), method + " must not be idempotent");
+ }
+ }
+
+ @Nested
+ @DisplayName("retry admission")
+ class RetryAdmission {
+
+ @Test
+ @DisplayName("permits retry for an idempotent method with zero body bytes sent on a retry-enabled route")
+ void permitsRetryWhenSafe() {
+ StreamAwareRetryGate gate = new StreamAwareRetryGate(true);
+ assertTrue(gate.allowsRetry(HttpMethod.GET, 0L));
+ }
+
+ @Test
+ @DisplayName("never retries once any request body byte has been sent")
+ void neverRetriesAfterFirstBodyByte() {
+ StreamAwareRetryGate gate = new StreamAwareRetryGate(true);
+ assertFalse(gate.allowsRetry(HttpMethod.PUT, 1L),
+ "a streamed request cannot be replayed once a body byte has crossed to the upstream");
+ }
+
+ @Test
+ @DisplayName("never retries a non-idempotent method even with zero body bytes")
+ void neverRetriesNonIdempotent() {
+ StreamAwareRetryGate gate = new StreamAwareRetryGate(true);
+ assertFalse(gate.allowsRetry(HttpMethod.POST, 0L));
+ }
+
+ @Test
+ @DisplayName("never retries when the route disables retry")
+ void neverRetriesWhenRouteDisablesRetry() {
+ StreamAwareRetryGate gate = new StreamAwareRetryGate(false);
+ assertFalse(gate.allowsRetry(HttpMethod.GET, 0L));
+ }
+ }
+}
diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/api/edge/UpstreamFailureMapperTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/api/edge/UpstreamFailureMapperTest.java
new file mode 100644
index 0000000..5ac7a6a
--- /dev/null
+++ b/api-sheriff/src/test/java/de/cuioss/sheriff/api/edge/UpstreamFailureMapperTest.java
@@ -0,0 +1,143 @@
+/*
+ * 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.edge;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertSame;
+
+import java.io.IOException;
+import java.io.Serial;
+
+
+import de.cuioss.sheriff.api.events.EventType;
+import de.cuioss.sheriff.api.events.GatewayEventCounter;
+import de.cuioss.sheriff.api.events.GatewayException;
+
+import io.smallrye.faulttolerance.api.CircuitBreakerState;
+import org.eclipse.microprofile.faulttolerance.exceptions.CircuitBreakerOpenException;
+import org.eclipse.microprofile.faulttolerance.exceptions.TimeoutException;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+@DisplayName("UpstreamFailureMapper — stage 6/7 failure mapping to the error contract")
+class UpstreamFailureMapperTest {
+
+ private final GatewayEventCounter eventCounter = new GatewayEventCounter();
+ private final UpstreamFailureMapper mapper = new UpstreamFailureMapper(eventCounter);
+
+ @Nested
+ @DisplayName("failure classification")
+ class Classification {
+
+ @Test
+ @DisplayName("maps a breaker-open failure to 503 UPSTREAM_CIRCUIT_OPEN")
+ void breakerOpen() {
+ assertEquals(EventType.UPSTREAM_CIRCUIT_OPEN,
+ mapper.classify(new CircuitBreakerOpenException("open")));
+ }
+
+ @Test
+ @DisplayName("maps a MicroProfile timeout to 504 UPSTREAM_TIMEOUT")
+ void microProfileTimeout() {
+ assertEquals(EventType.UPSTREAM_TIMEOUT,
+ mapper.classify(new TimeoutException("t")));
+ }
+
+ @Test
+ @DisplayName("maps a java.util.concurrent timeout to 504 UPSTREAM_TIMEOUT")
+ void concurrentTimeout() {
+ assertEquals(EventType.UPSTREAM_TIMEOUT,
+ mapper.classify(new java.util.concurrent.TimeoutException("t")));
+ }
+
+ @Test
+ @DisplayName("maps any Timeout-named failure to 504 UPSTREAM_TIMEOUT")
+ void timeoutNamedFailure() {
+ assertEquals(EventType.UPSTREAM_TIMEOUT, mapper.classify(new ConnectionTimeoutError()));
+ }
+
+ @Test
+ @DisplayName("unwraps a wrapped breaker-open cause")
+ void wrappedBreakerOpen() {
+ Throwable wrapped = new IllegalStateException(new CircuitBreakerOpenException("open"));
+ assertEquals(EventType.UPSTREAM_CIRCUIT_OPEN, mapper.classify(wrapped));
+ }
+
+ @Test
+ @DisplayName("maps a generic connection failure to 502 UPSTREAM_ERROR")
+ void genericFailure() {
+ assertEquals(EventType.UPSTREAM_ERROR, mapper.classify(new IOException("connection refused")));
+ }
+
+ @Test
+ @DisplayName("terminates on a self-referential cause cycle")
+ void selfReferentialCause() {
+ SelfCausingException cyclic = new SelfCausingException();
+ assertEquals(EventType.UPSTREAM_ERROR, mapper.classify(cyclic));
+ }
+ }
+
+ @Nested
+ @DisplayName("metering and exception production")
+ class MeteringAndException {
+
+ @Test
+ @DisplayName("meters and wraps a failure into the typed GatewayException")
+ void toGatewayExceptionMetersAndWraps() {
+ IOException cause = new IOException("boom");
+
+ GatewayException produced = mapper.toGatewayException(cause);
+
+ assertEquals(EventType.UPSTREAM_ERROR, produced.getEventType());
+ assertSame(cause, produced.getCause(), "the internal cause must be preserved for logging");
+ assertEquals(1L, eventCounter.getCount(EventType.UPSTREAM_ERROR),
+ "every mapped failure is metered");
+ }
+
+ @Test
+ @DisplayName("meters a breaker OPEN transition")
+ void recordsBreakerOpen() {
+ mapper.recordBreakerTransition("orders", CircuitBreakerState.OPEN);
+ assertEquals(1L, eventCounter.getCount(EventType.UPSTREAM_CIRCUIT_OPEN));
+ }
+
+ @Test
+ @DisplayName("does not meter a breaker CLOSED / HALF_OPEN transition")
+ void ignoresNonOpenTransitions() {
+ mapper.recordBreakerTransition("orders", CircuitBreakerState.CLOSED);
+ mapper.recordBreakerTransition("orders", CircuitBreakerState.HALF_OPEN);
+ assertEquals(0L, eventCounter.getCount(EventType.UPSTREAM_CIRCUIT_OPEN));
+ }
+ }
+
+ /** A failure whose simple class name contains {@code Timeout}, matching the name-based fallback. */
+ private static final class ConnectionTimeoutError extends RuntimeException {
+ @Serial
+ private static final long serialVersionUID = 1L;
+ }
+
+ /** A failure that returns itself as its own cause, exercising the cause-cycle guard. */
+ private static final class SelfCausingException extends RuntimeException {
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+ @Override
+ public synchronized Throwable getCause() {
+ return this;
+ }
+ }
+}
diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/api/events/EventTypeTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/api/events/EventTypeTest.java
new file mode 100644
index 0000000..eca92fb
--- /dev/null
+++ b/api-sheriff/src/test/java/de/cuioss/sheriff/api/events/EventTypeTest.java
@@ -0,0 +1,106 @@
+/*
+ * 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.events;
+
+import static org.junit.jupiter.api.Assertions.assertAll;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.junit.jupiter.params.provider.EnumSource;
+
+@DisplayName("EventType — category and HTTP-status mapping")
+class EventTypeTest {
+
+ @Nested
+ @DisplayName("Success / informational events")
+ class SuccessEvents {
+
+ @ParameterizedTest
+ @EnumSource(names = {"REQUEST_FORWARDED", "TOKEN_REFRESHED", "CONFIG_LOADED"})
+ @DisplayName("Should carry no category and no HTTP mapping")
+ void shouldCarryNoCategoryAndNoHttpMapping(EventType eventType) {
+ assertAll("success event " + eventType,
+ () -> assertNull(eventType.category(), "Success events carry a null category"),
+ () -> assertFalse(eventType.isFailure(), "Success events are not failures"),
+ () -> assertEquals(0, eventType.httpStatus(), "Success events have no HTTP status"),
+ () -> assertFalse(eventType.hasHttpMapping(), "Success events have no HTTP mapping"));
+ }
+ }
+
+ @Nested
+ @DisplayName("Boot-only configuration events")
+ class ConfigurationEvents {
+
+ @ParameterizedTest
+ @EnumSource(names = {"CONFIG_INVALID", "AUTH_WEAKENED"})
+ @DisplayName("Should be failures in the CONFIGURATION category with no HTTP mapping")
+ void shouldBeConfigurationFailuresWithoutHttpMapping(EventType eventType) {
+ assertAll("configuration event " + eventType,
+ () -> assertEquals(EventCategory.CONFIGURATION, eventType.category(),
+ "Configuration events carry the CONFIGURATION category"),
+ () -> assertTrue(eventType.isFailure(), "Configuration events are failures"),
+ () -> assertEquals(0, eventType.httpStatus(), "Configuration events never surface as HTTP"),
+ () -> assertFalse(eventType.hasHttpMapping(), "Configuration events have no HTTP mapping"));
+ }
+ }
+
+ @Nested
+ @DisplayName("Request-time failure events (error contract)")
+ class FailureEvents {
+
+ @ParameterizedTest(name = "{0} -> {1} ({2})")
+ @CsvSource({
+ "SECURITY_FILTER_VIOLATION, 400, INPUT_VALIDATION",
+ "PATH_NOT_ALLOWED, 400, INPUT_VALIDATION",
+ "PARAMETER_LIMIT_EXCEEDED, 400, INPUT_VALIDATION",
+ "NO_ROUTE_MATCHED, 404, INPUT_VALIDATION",
+ "METHOD_NOT_ALLOWED, 405, INPUT_VALIDATION",
+ "TOKEN_MISSING, 401, AUTHENTICATION",
+ "TOKEN_INVALID, 401, AUTHENTICATION",
+ "SCOPE_MISSING, 403, AUTHORIZATION",
+ "CSRF_REJECTED, 403, AUTHORIZATION",
+ "UPSTREAM_ERROR, 502, UPSTREAM",
+ "UPSTREAM_CIRCUIT_OPEN, 503, UPSTREAM",
+ "UPSTREAM_TIMEOUT, 504, UPSTREAM"
+ })
+ @DisplayName("Should map each error-contract row to its status and category")
+ void shouldMapEachErrorContractRow(EventType eventType, int expectedStatus, EventCategory expectedCategory) {
+ assertAll("failure event " + eventType,
+ () -> assertEquals(expectedCategory, eventType.category(), "Category must match the contract"),
+ () -> assertEquals(expectedStatus, eventType.httpStatus(), "Status must match the contract"),
+ () -> assertTrue(eventType.isFailure(), "Request-time rejections are failures"),
+ () -> assertTrue(eventType.hasHttpMapping(), "Request-time rejections map to a status"));
+ }
+ }
+
+ @Test
+ @DisplayName("Should derive the RFC 9457 problem type from the category slug")
+ void shouldDeriveProblemTypeFromCategorySlug() {
+ assertAll("problem type",
+ () -> assertEquals("urn:api-sheriff:problem:input-validation",
+ EventCategory.INPUT_VALIDATION.problemType()),
+ () -> assertEquals("urn:api-sheriff:problem:upstream",
+ EventCategory.UPSTREAM.problemType()),
+ () -> assertEquals("Input Validation", EventCategory.INPUT_VALIDATION.title()));
+ }
+}
diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/api/events/GatewayEventCounterTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/api/events/GatewayEventCounterTest.java
new file mode 100644
index 0000000..1714b9f
--- /dev/null
+++ b/api-sheriff/src/test/java/de/cuioss/sheriff/api/events/GatewayEventCounterTest.java
@@ -0,0 +1,93 @@
+/*
+ * 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.events;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.Map;
+import java.util.stream.IntStream;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+@DisplayName("GatewayEventCounter — lock-free in-process counter")
+class GatewayEventCounterTest {
+
+ @Test
+ @DisplayName("Should report zero for an event that was never incremented")
+ void shouldReportZeroForUncountedEvent() {
+ var counter = new GatewayEventCounter();
+
+ long count = counter.getCount(EventType.REQUEST_FORWARDED);
+
+ assertEquals(0L, count, "An uncounted event must report zero");
+ }
+
+ @Test
+ @DisplayName("Should increment a single event type")
+ void shouldIncrementSingleEventType() {
+ var counter = new GatewayEventCounter();
+
+ counter.increment(EventType.TOKEN_INVALID);
+ counter.increment(EventType.TOKEN_INVALID);
+ counter.increment(EventType.TOKEN_INVALID);
+
+ assertEquals(3L, counter.getCount(EventType.TOKEN_INVALID), "Three increments must yield three");
+ }
+
+ @Test
+ @DisplayName("Should count concurrent increments without loss (lock-free)")
+ void shouldCountConcurrentIncrementsWithoutLoss() {
+ var counter = new GatewayEventCounter();
+ int increments = 10_000;
+
+ IntStream.range(0, increments).parallel()
+ .forEach(i -> counter.increment(EventType.SECURITY_FILTER_VIOLATION));
+
+ assertEquals(increments, counter.getCount(EventType.SECURITY_FILTER_VIOLATION),
+ "Every concurrent increment must be counted");
+ }
+
+ @Test
+ @DisplayName("Should expose an unmodifiable snapshot of all non-zero counters")
+ void shouldExposeUnmodifiableSnapshot() {
+ var counter = new GatewayEventCounter();
+ counter.increment(EventType.UPSTREAM_ERROR);
+ counter.increment(EventType.SCOPE_MISSING);
+
+ Map snapshot = counter.getCounters();
+
+ assertEquals(2, snapshot.size(), "Only incremented events appear in the snapshot");
+ assertEquals(1L, snapshot.get(EventType.UPSTREAM_ERROR), "Snapshot value must match the count");
+ assertThrows(UnsupportedOperationException.class,
+ () -> snapshot.put(EventType.CONFIG_LOADED, 5L),
+ "The snapshot must be unmodifiable");
+ }
+
+ @Test
+ @DisplayName("Should clear every counter on reset")
+ void shouldClearEveryCounterOnReset() {
+ var counter = new GatewayEventCounter();
+ counter.increment(EventType.NO_ROUTE_MATCHED);
+
+ counter.reset();
+
+ assertEquals(0L, counter.getCount(EventType.NO_ROUTE_MATCHED), "Reset must zero the counter");
+ assertTrue(counter.getCounters().isEmpty(), "Reset must empty the snapshot");
+ }
+}
diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/api/forward/ForwardPolicyStageTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/api/forward/ForwardPolicyStageTest.java
new file mode 100644
index 0000000..bc6ead0
--- /dev/null
+++ b/api-sheriff/src/test/java/de/cuioss/sheriff/api/forward/ForwardPolicyStageTest.java
@@ -0,0 +1,288 @@
+/*
+ * 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.forward;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+
+import de.cuioss.http.forwarded.ForwardedHeaderResolver;
+import de.cuioss.http.forwarded.ForwardedResolverConfig;
+import de.cuioss.http.security.monitoring.SecurityEventCounter;
+import de.cuioss.sheriff.api.config.model.ForwardConfig;
+import de.cuioss.sheriff.api.config.model.HttpMethod;
+import de.cuioss.sheriff.api.pipeline.PipelineRequest;
+
+import org.jspecify.annotations.Nullable;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+@DisplayName("ForwardPolicyStage — stage 5 zero-trust forward policy")
+class ForwardPolicyStageTest {
+
+ private static final String EMIT_XFORWARDED = "x-forwarded";
+ private static final String EMIT_BOTH = "both";
+
+ /** CIDR range trusted both as the immediate TCP peer and as an intermediate proxy. */
+ private static final String TRUSTED_CIDR = "127.0.0.0/8";
+ /** An immediate TCP peer inside {@link #TRUSTED_CIDR}. */
+ private static final String TRUSTED_PEER = "127.0.0.1";
+ /** An immediate TCP peer outside every trusted range. */
+ private static final String UNTRUSTED_PEER = "203.0.113.9";
+ /** The real client the forwarding chain resolves to (never a trusted proxy). */
+ private static final String CLIENT_IP = "6.6.6.6";
+ private static final String XFF = "X-Forwarded-For";
+ private static final String FORWARDED = "Forwarded";
+
+ @Nested
+ @DisplayName("drop-and-regenerate forwarding headers")
+ class DropAndRegenerate {
+
+ @Test
+ @DisplayName("regenerates X-Forwarded-For from a trusted peer chain, stripping the proxy hop")
+ void regeneratesClientIpStrippingProxy() {
+ // Arrange — a two-hop chain: real client 6.6.6.6 behind trusted proxy 127.0.0.1
+ ForwardPolicyStage stage = stage(EMIT_XFORWARDED, List.of(TRUSTED_CIDR), Set.of(TRUSTED_CIDR));
+ PipelineRequest request = request(TRUSTED_PEER,
+ Map.of(XFF, List.of(CLIENT_IP + ", " + TRUSTED_PEER)));
+
+ // Act
+ ForwardPolicyStage.Result result = stage.process(request, allow(List.of(XFF)), false);
+
+ // Assert — the regenerated value is the client only, never the verbatim inbound chain
+ assertTrue(result.headers().containsValue(CLIENT_IP),
+ "regenerated X-Forwarded-For must carry the resolved client IP");
+ assertFalse(result.headers().containsValue(CLIENT_IP + ", " + TRUSTED_PEER),
+ "inbound forwarding chain must never be propagated verbatim");
+ }
+
+ @Test
+ @DisplayName("emit:both additionally regenerates the RFC 7239 Forwarded header")
+ void emitBothAddsForwardedHeader() {
+ // Arrange
+ ForwardPolicyStage stage = stage(EMIT_BOTH, List.of(TRUSTED_CIDR), Set.of(TRUSTED_CIDR));
+ PipelineRequest request = request(TRUSTED_PEER, Map.of(XFF, List.of(CLIENT_IP)));
+
+ // Act
+ ForwardPolicyStage.Result result = stage.process(request, allow(List.of()), false);
+
+ // Assert — both X-Forwarded-* and Forwarded are emitted
+ assertTrue(result.headers().containsValue(CLIENT_IP),
+ "emit:both must still regenerate the X-Forwarded-* set");
+ assertTrue(result.headers().containsKey(FORWARDED),
+ "emit:both must add the RFC 7239 Forwarded header");
+ assertTrue(result.headers().get(FORWARDED).contains(CLIENT_IP),
+ "the regenerated Forwarded header must reference the resolved client");
+ }
+
+ @Test
+ @DisplayName("emit:x-forwarded omits the Forwarded header but keeps X-Forwarded-*")
+ void emitXForwardedOmitsForwardedHeader() {
+ // Arrange
+ ForwardPolicyStage stage = stage(EMIT_XFORWARDED, List.of(TRUSTED_CIDR), Set.of(TRUSTED_CIDR));
+ PipelineRequest request = request(TRUSTED_PEER, Map.of(XFF, List.of(CLIENT_IP)));
+
+ // Act
+ ForwardPolicyStage.Result result = stage.process(request, allow(List.of()), false);
+
+ // Assert
+ assertTrue(result.headers().containsValue(CLIENT_IP),
+ "emit:x-forwarded must regenerate the X-Forwarded-* set");
+ assertFalse(result.headers().containsKey(FORWARDED),
+ "emit:x-forwarded must never add the RFC 7239 Forwarded header");
+ }
+ }
+
+ @Nested
+ @DisplayName("spoofed headers from untrusted peers are ignored")
+ class UntrustedPeer {
+
+ @Test
+ @DisplayName("an untrusted immediate peer's spoofed X-Forwarded-For never influences the regenerated set")
+ void untrustedPeerSpoofIgnored() {
+ // Arrange — untrusted peer sends a spoofed forwarding header, even allow-listed
+ ForwardPolicyStage stage = stage(EMIT_BOTH, List.of(TRUSTED_CIDR), Set.of(TRUSTED_CIDR));
+ PipelineRequest request = request(UNTRUSTED_PEER, Map.of(XFF, List.of(CLIENT_IP)));
+
+ // Act
+ ForwardPolicyStage.Result result = stage.process(request, allow(List.of(XFF)), false);
+
+ // Assert — the spoofed value crosses neither as X-Forwarded-For nor as Forwarded
+ assertFalse(result.headers().containsValue(CLIENT_IP),
+ "a spoofed forwarding header from an untrusted peer must be ignored");
+ assertFalse(result.headers().containsKey(FORWARDED),
+ "no Forwarded header may be regenerated from ignored spoofed input");
+ }
+
+ @Test
+ @DisplayName("a null immediate peer is never trusted, so inbound forwarding headers are ignored")
+ void nullPeerNeverTrusted() {
+ // Arrange — the edge supplied no peer address
+ ForwardPolicyStage stage = stage(EMIT_XFORWARDED, List.of(TRUSTED_CIDR), Set.of(TRUSTED_CIDR));
+ PipelineRequest request = request(null, Map.of(XFF, List.of(CLIENT_IP)));
+
+ // Act
+ ForwardPolicyStage.Result result = stage.process(request, allow(List.of(XFF)), false);
+
+ // Assert
+ assertFalse(result.headers().containsValue(CLIENT_IP),
+ "an absent peer address is never trusted");
+ }
+ }
+
+ @Nested
+ @DisplayName("deny-by-default allowlists")
+ class Allowlists {
+
+ @Test
+ @DisplayName("forwards only allow-listed request headers")
+ void forwardsOnlyAllowListedHeaders() {
+ // Arrange
+ ForwardPolicyStage stage = stage(EMIT_XFORWARDED, List.of(), Set.of());
+ PipelineRequest request = request(UNTRUSTED_PEER, Map.of(
+ "X-Api-Version", List.of("v2"),
+ "X-Secret", List.of("leak")));
+
+ // Act
+ ForwardPolicyStage.Result result = stage.process(request, allow(List.of("X-Api-Version")), false);
+
+ // Assert
+ assertEquals("v2", result.headers().get("X-Api-Version"), "allow-listed header must cross");
+ assertFalse(result.headers().containsKey("X-Secret"), "non-allow-listed header must be dropped");
+ }
+
+ @Test
+ @DisplayName("Authorization crosses only when explicitly allow-listed")
+ void authorizationCrossesOnlyWhenAllowListed() {
+ // Arrange
+ Map> headers = Map.of("Authorization", List.of("Bearer token-xyz"));
+
+ // Act — dropped when not listed, crosses when listed
+ ForwardPolicyStage.Result dropped = stage(EMIT_XFORWARDED, List.of(), Set.of())
+ .process(request(UNTRUSTED_PEER, headers), allow(List.of()), false);
+ ForwardPolicyStage.Result crossed = stage(EMIT_XFORWARDED, List.of(), Set.of())
+ .process(request(UNTRUSTED_PEER, headers), allow(List.of("Authorization")), false);
+
+ // Assert
+ assertFalse(dropped.headers().containsKey("Authorization"),
+ "inbound Authorization is dropped by default");
+ assertEquals("Bearer token-xyz", crossed.headers().get("Authorization"),
+ "inbound Authorization crosses only when allow-listed");
+ }
+
+ @Test
+ @DisplayName("an inbound forwarding header can never be smuggled through the header allowlist")
+ void forwardingHeaderNeverAllowListed() {
+ // Arrange — X-Forwarded-For is allow-listed AND the peer is untrusted
+ ForwardPolicyStage stage = stage(EMIT_XFORWARDED, List.of(TRUSTED_CIDR), Set.of(TRUSTED_CIDR));
+ PipelineRequest request = request(UNTRUSTED_PEER, Map.of(XFF, List.of(CLIENT_IP)));
+
+ // Act
+ ForwardPolicyStage.Result result = stage.process(request, allow(List.of(XFF)), false);
+
+ // Assert — the verbatim inbound value is never copied through the allowlist path
+ assertFalse(result.headers().containsValue(CLIENT_IP),
+ "a forwarding header is excluded from the allowlist copy and never propagated verbatim");
+ }
+
+ @Test
+ @DisplayName("forwards only allow-listed query parameters")
+ void forwardsOnlyAllowListedQuery() {
+ // Arrange
+ ForwardPolicyStage stage = stage(EMIT_XFORWARDED, List.of(), Set.of());
+ PipelineRequest request = PipelineRequest.builder()
+ .method(HttpMethod.GET)
+ .requestPath("/api/orders")
+ .peerAddress(UNTRUSTED_PEER)
+ .queryParameters(Map.of("page", List.of("2"), "secret", List.of("x")))
+ .build();
+
+ // Act
+ ForwardPolicyStage.Result result = stage.process(request,
+ ForwardConfig.builder().queryAllow(List.of("page")).build(), false);
+
+ // Assert
+ assertEquals(List.of("2"), result.query().get("page"), "allow-listed query parameter must cross");
+ assertFalse(result.query().containsKey("secret"), "non-allow-listed query parameter must be dropped");
+ }
+ }
+
+ @Nested
+ @DisplayName("static and conditional headers")
+ class StaticAndConditional {
+
+ @Test
+ @DisplayName("appends static set_headers verbatim")
+ void appendsStaticSetHeaders() {
+ // Arrange
+ ForwardPolicyStage stage = stage(EMIT_XFORWARDED, List.of(), Set.of());
+ PipelineRequest request = request(UNTRUSTED_PEER, Map.of());
+
+ // Act
+ ForwardPolicyStage.Result result = stage.process(request,
+ ForwardConfig.builder().setHeaders(Map.of("X-Gateway", "sheriff")).build(), false);
+
+ // Assert
+ assertEquals("sheriff", result.headers().get("X-Gateway"), "static set_headers must be appended verbatim");
+ }
+
+ @Test
+ @DisplayName("conditional-request headers cross only when the route enables not_modified")
+ void conditionalHeadersGatedByNotModified() {
+ // Arrange
+ Map> headers = Map.of("If-None-Match", List.of("\"etag-1\""));
+
+ // Act
+ ForwardPolicyStage.Result dropped = stage(EMIT_XFORWARDED, List.of(), Set.of())
+ .process(request(UNTRUSTED_PEER, headers), allow(List.of()), false);
+ ForwardPolicyStage.Result crossed = stage(EMIT_XFORWARDED, List.of(), Set.of())
+ .process(request(UNTRUSTED_PEER, headers), allow(List.of()), true);
+
+ // Assert
+ assertFalse(dropped.headers().containsKey("If-None-Match"),
+ "conditional headers are dropped when not_modified is disabled");
+ assertEquals("\"etag-1\"", crossed.headers().get("If-None-Match"),
+ "conditional headers cross when not_modified is enabled");
+ }
+ }
+
+ private static ForwardPolicyStage stage(String emitMode, List tcpTrusted, Set resolverTrusted) {
+ ForwardedResolverConfig config = ForwardedResolverConfig.builder()
+ .trustedProxies(resolverTrusted)
+ .build();
+ ForwardedHeaderResolver resolver = new ForwardedHeaderResolver(config, new SecurityEventCounter());
+ return new ForwardPolicyStage(resolver, new TcpPeerGate(tcpTrusted), emitMode);
+ }
+
+ private static ForwardConfig allow(List headersAllow) {
+ return ForwardConfig.builder().headersAllow(headersAllow).build();
+ }
+
+ private static PipelineRequest request(@Nullable String peer, Map> headers) {
+ return PipelineRequest.builder()
+ .method(HttpMethod.GET)
+ .requestPath("/api/orders")
+ .peerAddress(peer)
+ .headers(headers)
+ .build();
+ }
+}
diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/api/forward/TcpPeerGateTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/api/forward/TcpPeerGateTest.java
new file mode 100644
index 0000000..3908f40
--- /dev/null
+++ b/api-sheriff/src/test/java/de/cuioss/sheriff/api/forward/TcpPeerGateTest.java
@@ -0,0 +1,179 @@
+/*
+ * 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.forward;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.List;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.junit.jupiter.params.provider.ValueSource;
+
+@DisplayName("TcpPeerGate — immediate-TCP-peer trust gate (ADR-0003)")
+class TcpPeerGateTest {
+
+ @Nested
+ @DisplayName("IPv4 CIDR membership")
+ class IPv4Membership {
+
+ @ParameterizedTest
+ @CsvSource({
+ "127.0.0.0/8,127.0.0.1",
+ "127.0.0.0/8,127.255.255.254",
+ "10.0.0.0/8,10.9.9.9",
+ "192.168.1.0/24,192.168.1.0",
+ "192.168.1.0/24,192.168.1.255",
+ "203.0.113.5/32,203.0.113.5"
+ })
+ @DisplayName("trusts a peer inside the configured range")
+ void trustsPeerInsideRange(String cidr, String peer) {
+ // Arrange
+ TcpPeerGate gate = new TcpPeerGate(List.of(cidr));
+
+ // Act + Assert
+ assertTrue(gate.isTrustedPeer(peer), peer + " must be trusted inside " + cidr);
+ }
+
+ @ParameterizedTest
+ @CsvSource({
+ "127.0.0.0/8,128.0.0.1",
+ "10.0.0.0/8,11.0.0.1",
+ "192.168.1.0/24,192.168.2.1",
+ "203.0.113.5/32,203.0.113.6"
+ })
+ @DisplayName("rejects a peer outside the configured range")
+ void rejectsPeerOutsideRange(String cidr, String peer) {
+ // Arrange
+ TcpPeerGate gate = new TcpPeerGate(List.of(cidr));
+
+ // Act + Assert
+ assertFalse(gate.isTrustedPeer(peer), peer + " must be rejected outside " + cidr);
+ }
+
+ @Test
+ @DisplayName("trusts a peer matching any of several configured ranges")
+ void trustsAcrossMultipleRanges() {
+ // Arrange
+ TcpPeerGate gate = new TcpPeerGate(List.of("10.0.0.0/8", "192.168.0.0/16"));
+
+ // Act + Assert
+ assertTrue(gate.isTrustedPeer("10.1.2.3"));
+ assertTrue(gate.isTrustedPeer("192.168.50.1"));
+ assertFalse(gate.isTrustedPeer("172.16.0.1"));
+ }
+ }
+
+ @Nested
+ @DisplayName("IPv6 CIDR membership is address-family aware")
+ class IPv6Membership {
+
+ @Test
+ @DisplayName("trusts an IPv6 peer inside an IPv6 range")
+ void trustsIpv6PeerInsideRange() {
+ // Arrange
+ TcpPeerGate gate = new TcpPeerGate(List.of("2001:db8::/32"));
+
+ // Act + Assert
+ assertTrue(gate.isTrustedPeer("2001:db8::1"));
+ assertFalse(gate.isTrustedPeer("2001:dead::1"));
+ }
+
+ @Test
+ @DisplayName("an IPv4 peer never matches an IPv6 range and vice versa")
+ void crossFamilyNeverMatches() {
+ // Arrange
+ TcpPeerGate ipv6Gate = new TcpPeerGate(List.of("2001:db8::/32"));
+ TcpPeerGate ipv4Gate = new TcpPeerGate(List.of("10.0.0.0/8"));
+
+ // Act + Assert
+ assertFalse(ipv6Gate.isTrustedPeer("10.0.0.1"), "IPv4 candidate must not match an IPv6 range");
+ assertFalse(ipv4Gate.isTrustedPeer("2001:db8::1"), "IPv6 candidate must not match an IPv4 range");
+ }
+ }
+
+ @Nested
+ @DisplayName("fail-closed behaviour")
+ class FailClosed {
+
+ @Test
+ @DisplayName("an empty trusted set trusts no peer")
+ void emptySetTrustsNoPeer() {
+ // Arrange
+ TcpPeerGate gate = new TcpPeerGate(List.of());
+
+ // Act + Assert
+ assertFalse(gate.isTrustedPeer("127.0.0.1"), "an empty trusted set must trust no peer");
+ }
+
+ @Test
+ @DisplayName("a null peer address is never trusted")
+ void nullPeerNeverTrusted() {
+ // Arrange
+ TcpPeerGate gate = new TcpPeerGate(List.of("0.0.0.0/0"));
+
+ // Act + Assert
+ assertFalse(gate.isTrustedPeer(null), "a null peer must never be trusted, even under 0.0.0.0/0");
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"not-an-ip", "999.0.0.1", "127.0.0.1/8", ""})
+ @DisplayName("an unparseable peer address is never trusted")
+ void unparseablePeerNeverTrusted(String peer) {
+ // Arrange
+ TcpPeerGate gate = new TcpPeerGate(List.of("127.0.0.0/8"));
+
+ // Act + Assert
+ assertFalse(gate.isTrustedPeer(peer), "an unparseable peer literal must never be trusted");
+ }
+
+ @Test
+ @DisplayName("surrounding whitespace on the peer address is stripped before matching")
+ void stripsSurroundingWhitespace() {
+ // Arrange
+ TcpPeerGate gate = new TcpPeerGate(List.of("127.0.0.0/8"));
+
+ // Act + Assert
+ assertTrue(gate.isTrustedPeer(" 127.0.0.1 "), "a padded but valid peer literal must be trusted");
+ }
+ }
+
+ @Nested
+ @DisplayName("boot-time CIDR parsing rejects malformed input")
+ class CidrParsing {
+
+ @ParameterizedTest
+ @ValueSource(strings = {"127.0.0.1", "10.0.0.0/99", "10.0.0.0/-1", "999.0.0.0/8", "not-a-cidr"})
+ @DisplayName("rejects a malformed trusted-proxy CIDR at construction")
+ void rejectsMalformedCidr(String badCidr) {
+ // Act + Assert
+ assertThrows(IllegalArgumentException.class, () -> new TcpPeerGate(List.of(badCidr)),
+ "malformed CIDR must fail loud at boot");
+ }
+
+ @Test
+ @DisplayName("rejects a null CIDR collection")
+ void rejectsNullCollection() {
+ // Act + Assert
+ assertThrows(NullPointerException.class, () -> new TcpPeerGate(null));
+ }
+ }
+}
diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/api/gateway/GatewayResourceTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/api/gateway/GatewayResourceTest.java
deleted file mode 100644
index e330c79..0000000
--- a/api-sheriff/src/test/java/de/cuioss/sheriff/api/gateway/GatewayResourceTest.java
+++ /dev/null
@@ -1,63 +0,0 @@
-/*
- * 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.gateway;
-
-import static io.restassured.RestAssured.given;
-import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertNotNull;
-
-
-import org.junit.jupiter.api.BeforeAll;
-import org.junit.jupiter.api.Test;
-
-import io.quarkus.test.junit.QuarkusTest;
-import io.restassured.RestAssured;
-
-/**
- * Tests for {@link GatewayResource}.
- */
-@QuarkusTest
-class GatewayResourceTest {
-
- @BeforeAll
- static void setup() {
- RestAssured.useRelaxedHTTPSValidation();
- }
-
- @Test
- void shouldReturnHealthUp() {
- var response = given()
- .when().get("/api/health")
- .then()
- .statusCode(200)
- .extract();
-
- assertEquals("UP", response.path("status"));
- assertEquals("API Sheriff is operational", response.path("apiSheriff"));
- }
-
- @Test
- void shouldReturnInfo() {
- var response = given()
- .when().get("/api/info")
- .then()
- .statusCode(200)
- .extract();
-
- assertEquals("API Sheriff Gateway", response.path("message"));
- assertNotNull(response.path("version"));
- }
-}
diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/api/gateway/proxy/ProxyRouteTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/api/gateway/proxy/ProxyRouteTest.java
deleted file mode 100644
index a033288..0000000
--- a/api-sheriff/src/test/java/de/cuioss/sheriff/api/gateway/proxy/ProxyRouteTest.java
+++ /dev/null
@@ -1,269 +0,0 @@
-/*
- * 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.gateway.proxy;
-
-import static io.restassured.RestAssured.given;
-import static org.junit.jupiter.api.Assertions.assertEquals;
-
-import java.io.IOException;
-import java.util.Map;
-import java.util.Optional;
-import java.util.Set;
-import java.util.concurrent.TimeUnit;
-
-
-import org.junit.jupiter.api.BeforeAll;
-import org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.api.Test;
-
-import com.fasterxml.jackson.core.JsonProcessingException;
-import com.fasterxml.jackson.databind.ObjectMapper;
-
-import de.cuioss.test.mockwebserver.EnableMockWebServer;
-import de.cuioss.test.mockwebserver.dispatcher.HttpMethodMapper;
-import de.cuioss.test.mockwebserver.dispatcher.ModuleDispatcher;
-import de.cuioss.test.mockwebserver.dispatcher.ModuleDispatcherElement;
-import io.quarkus.test.junit.QuarkusTest;
-import io.restassured.RestAssured;
-import lombok.NonNull;
-import mockwebserver3.MockResponse;
-import mockwebserver3.MockWebServer;
-import mockwebserver3.RecordedRequest;
-import okio.ByteString;
-
-/**
- * Tests for {@link ProxyRoute}: forwarding of method, path remainder, query and
- * body to the upstream, hop-by-hop header stripping, {@code 404}
- * deny-by-default for unmatched paths, and the two disposable stopgap mitigations
- * (a {@code BodyHandler} request-body size limit and an {@code HttpRequest}
- * upstream timeout).
- *
- * The proxy edge sources its upstream from the {@code RouteTable} the
- * configuration subsystem assembles at boot: the test config
- * ({@code src/test/resources/config/testboot}) declares a single {@code /proxy}
- * route ({@code endpoints/proxy.yaml}) whose {@code UPSTREAM} topology alias
- * ({@code topology.properties}) resolves to the {@link MockWebServer} started on
- * the fixed port below. There is no static {@code ProxyConfiguration} bean any
- * more. The fixed port is required because {@code @QuarkusTest} freezes config at
- * boot, so a dynamically-assigned port cannot be injected. The mock echoes each
- * received request back as JSON, so the assertions verify exactly what the gateway
- * forwarded from the RouteTable-sourced upstream.
- */
-@QuarkusTest
-@EnableMockWebServer(manualStart = true)
-@ModuleDispatcher
-class ProxyRouteTest {
-
- /** Must match the {@code UPSTREAM} alias port in {@code config/testboot/topology.properties}. */
- private static final int UPSTREAM_PORT = 19191;
-
- /**
- * Mirrors {@code ProxyRoute}'s test-only upstream-timeout override property. Kept as a literal
- * (not shared code) so the test pins the exact contract the production seam exposes.
- */
- private static final String UPSTREAM_TIMEOUT_MS_PROPERTY = "apisheriff.proxy.upstream-request-timeout-ms";
-
- /** Path remainder the {@link EchoDispatcher} answers slowly, to trip the upstream request timeout. */
- private static final String SLOW_MARKER = "slow-upstream";
-
- @BeforeAll
- static void setup() {
- RestAssured.useRelaxedHTTPSValidation();
- }
-
- @BeforeEach
- void startUpstream(MockWebServer upstream) throws IOException {
- upstream.start(UPSTREAM_PORT);
- }
-
- public ModuleDispatcherElement getModuleDispatcher() {
- return new EchoDispatcher();
- }
-
- @Test
- void shouldForwardMethodPathAndQuery() {
- var response = given()
- .when().get("/proxy/orders/42?page=2&size=10")
- .then()
- .statusCode(200)
- .extract();
-
- assertEquals("GET", response.path("method"));
- assertEquals("/orders/42?page=2&size=10", response.path("target"));
- }
-
- @Test
- void shouldForwardBodyOnPost() {
- var response = given()
- .contentType("text/plain")
- .body("hello-upstream")
- .when().post("/proxy/submit")
- .then()
- .statusCode(200)
- .extract();
-
- assertEquals("POST", response.path("method"));
- assertEquals("/submit", response.path("target"));
- assertEquals("hello-upstream", response.path("body"));
- }
-
- @Test
- void shouldForwardCustomHeaderButStripHopByHop() {
- var response = given()
- .header("X-Forward-Test", "custom-value")
- .header("Connection", "keep-alive")
- .when().get("/proxy/echo")
- .then()
- .statusCode(200)
- .extract();
-
- assertEquals("custom-value", response.path("customHeader"));
- assertEquals("", response.path("connectionHeader"));
- }
-
- @Test
- void shouldReturn404ForUnmatchedPath() {
- given()
- .when().get("/not-proxied/resource")
- .then()
- .statusCode(404);
- }
-
- @Test
- void shouldReturn502WhenUpstreamUnavailable(MockWebServer upstream) throws Exception {
- // Take the upstream down so the forward fails with a connection error
- upstream.close();
- given()
- .when().get("/proxy/unavailable")
- .then()
- .statusCode(502);
- }
-
- @Test
- void shouldRejectRequestBodyExceedingSizeLimit() {
- // BodyHandler.setBodyLimit(1 MiB): a body one byte over the limit is rejected
- // by Vert.x with 413 before the proxy ever forwards it upstream.
- String overLimitBody = "a".repeat(1024 * 1024 + 1);
- given()
- .contentType("text/plain")
- .body(overLimitBody)
- .when().post("/proxy/submit")
- .then()
- .statusCode(413);
- }
-
- @Test
- void shouldReturn502WhenUpstreamExceedsRequestTimeout() {
- // Override the upstream request timeout to a short value so the slow upstream
- // (EchoDispatcher delays the SLOW_MARKER path well beyond it) trips the
- // HttpRequest timeout, which surfaces as a 502 — without a multi-second wait.
- System.setProperty(UPSTREAM_TIMEOUT_MS_PROPERTY, "500");
- try {
- given()
- .when().get("/proxy/" + SLOW_MARKER)
- .then()
- .statusCode(502);
- } finally {
- System.clearProperty(UPSTREAM_TIMEOUT_MS_PROPERTY);
- }
- }
-
- @Test
- void shouldFallBackToDefaultTimeoutWhenOverrideNonPositive() {
- // A misconfigured non-positive override (0 or negative) must be rejected in favour
- // of the default timeout rather than producing a non-positive Duration, which
- // HttpRequest.Builder.timeout() would reject with IllegalArgumentException — turning
- // every proxied request into a 502. With the guard, a normal fast request still
- // forwards and returns 200.
- System.setProperty(UPSTREAM_TIMEOUT_MS_PROPERTY, "0");
- try {
- given()
- .when().get("/proxy/orders/42")
- .then()
- .statusCode(200);
- } finally {
- System.clearProperty(UPSTREAM_TIMEOUT_MS_PROPERTY);
- }
- }
-
- /**
- * Echoes each received request back as JSON so the test can assert exactly
- * what the gateway forwarded. Matches every forwarded path ({@code "/"} base URL).
- * A request whose target contains {@link #SLOW_MARKER} is answered with a long
- * header delay so the proxy's upstream request timeout trips first.
- */
- private static final class EchoDispatcher implements ModuleDispatcherElement {
-
- private static final ObjectMapper MAPPER = new ObjectMapper();
-
- @Override
- public String getBaseUrl() {
- return "/";
- }
-
- @Override
- public Optional handleGet(@NonNull RecordedRequest request) {
- return echo(request);
- }
-
- @Override
- public Optional handlePost(@NonNull RecordedRequest request) {
- return echo(request);
- }
-
- @Override
- public Optional handlePut(@NonNull RecordedRequest request) {
- return echo(request);
- }
-
- @Override
- public Optional handleDelete(@NonNull RecordedRequest request) {
- return echo(request);
- }
-
- @Override
- public @NonNull Set supportedMethods() {
- return Set.of(HttpMethodMapper.values());
- }
-
- private Optional