diff --git a/.plan/project-architecture/api-sheriff/enriched.json b/.plan/project-architecture/api-sheriff/enriched.json index 293f14fa..9683ffc6 100644 --- a/.plan/project-architecture/api-sheriff/enriched.json +++ b/.plan/project-architecture/api-sheriff/enriched.json @@ -4,7 +4,9 @@ "Asset-serving hot path is deliberately virtual-thread-blocking (pipeline dispatched via virtualThreadExecutor; event-loop hop only for the write) and security guards re-resolve per request (root toRealPath TOCTOU check in DirectoryAssetSource) \u2014 decline review-bot suggestions to wrap in vertx.executeBlocking or to cache the resolved root, both recur and both are by-design.", "Verify review-bot claims that a Vert.x API does not exist against the green build before acting: gemini-code-assist repeatedly asserted compilation errors for real Vert.x methods (e.g. HttpServerResponse.ended()) on edge-relay code, and the project consistently accepts these as false positives when the whole-tree build and native ITs are green." ], - "insights": [], + "insights": [ + "NeutralTlsConfigSource runs at a single ordinal above 300 so its projected quarkus.* TLS policy keys reliably outrank application.properties defaults, but it never emits quarkus.tls.key-store.* \u2014 key material stays deployment-bound. TlsServerCustomizer then binds min_version/cipher_suites/alpn onto the terminated HTTPS listener and fails closed at boot on an unsupported value; ManagementPlainHttpAudit (StartupEvent, WARN ApiSheriff-115) is the runtime tripwire for the one legitimate plain-HTTP management path, naming both downgrade routes." + ], "internal_dependencies": [], "key_dependencies": [ "io.quarkus:quarkus-resteasy-jackson", @@ -27,6 +29,15 @@ }, "de.cuioss.sheriff.api.quarkus": { "description": "CDI wiring: ApiSheriffProducer exposing application-scoped beans to the Quarkus container." + }, + "de.cuioss.sheriff.gateway.config": { + "description": "Config sourcing and route-table assembly. NeutralTlsConfigSource is a SmallRye ConfigSource at a single ordinal above 300 that projects neutral gateway.yaml TLS POLICY keys into the quarkus.* namespace; it deliberately never writes quarkus.tls.key-store.*, so the management interface cannot silently inherit HTTPS from the default TLS registry bucket." + }, + "de.cuioss.sheriff.gateway.config.model": { + "description": "Neutral configuration model records (GatewayConfig, RouteConfig, TlsConfig, SecurityHeadersConfig, etc.), including ManagementConfig \u2014 the neutral, policy-only management block (tls.enabled). It declares no port: ports stay deployment-bound, and a stray management.port is refused in-schema with a message naming quarkus.management.port / QUARKUS_MANAGEMENT_PORT." + }, + "de.cuioss.sheriff.gateway.tls": { + "description": "Terminated-HTTPS listener security: SNI front listener, mTLS customizer, and TlsServerCustomizer, which binds the neutral gateway.yaml tls policy (min_version, cipher_suites, alpn) onto the terminated HTTPS listener \u2014 these keys had no production consumer before this plan, so the declared TLS floor and cipher allowlist were not in force; all three now fail closed at boot with an actionable message on an unsupported value. ManagementPlainHttpAudit is a StartupEvent observer that emits WARN ApiSheriff-115 when the management interface resolves to plain HTTP, naming both downgrade routes (the gateway.yaml key and the QUARKUS_MANAGEMENT_TLS_CONFIGURATION_NAME override)." } }, "purpose": "runtime", @@ -174,6 +185,7 @@ "tips": [ "When deleting or moving a Java package's contents, package-info.java is a sibling that is easy to miss in consumer/deletion sweeps (caught here by the outline Q-Gate). Always include package-info.java in the deletion deliverable when a package is being emptied or removed.", "api-sheriff source uses Java unnamed variables ('_' in catch and lambda params, e.g. catch (IOException _), _ -> vertx.createHttpClient()) across edge/forward/asset packages. Unnamed variables (JEP 456) require compiler release >= 22. Keep maven.compiler.release at the project Java level (25); a stale or lowered -source below 22 fails compilation with 'unnamed variables are not supported in -source 21 (use -source 22 or higher)'.", - "JaCoCo enforces a 0.80 branch-coverage floor on api-sheriff and fails fast, which masks coverage failures in later modules (integration-tests was hidden behind it). When a coverage check fails here, establish provenance first with a baseline check against origin/main before treating it as branch-introduced - the 0.78 breach seen in plan benchmark-health-target was inherited debt from main, not new." + "JaCoCo enforces a 0.80 branch-coverage floor on api-sheriff and fails fast, which masks coverage failures in later modules (integration-tests was hidden behind it). When a coverage check fails here, establish provenance first with a baseline check against origin/main before treating it as branch-introduced - the 0.78 breach seen in plan benchmark-health-target was inherited debt from main, not new.", + "TLS-related config keys fall into three classes per doc/adr/0025-*: POLICY (neutral, gateway.yaml \u2014 min_version, cipher_suites, alpn, management.tls.enabled), DEPLOYMENT-BOUND (ports and trust material, environment-supplied), and BUILD-TIME (fixed at image build). Never add a new POLICY key that also carries a port or key-store path \u2014 that would re-introduce the pre-plan gap where a declared knob had no production consumer, or silently make the management interface inherit HTTPS from the default TLS registry bucket." ] } \ No newline at end of file diff --git a/api-sheriff/pom.xml b/api-sheriff/pom.xml index 37360dda..02707974 100644 --- a/api-sheriff/pom.xml +++ b/api-sheriff/pom.xml @@ -290,6 +290,14 @@ true true + + true diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/config/ConfigLogMessages.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/config/ConfigLogMessages.java index 245318cb..22910c27 100644 --- a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/config/ConfigLogMessages.java +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/config/ConfigLogMessages.java @@ -93,6 +93,29 @@ public static final class WARN { .identifier(102) .template("trusted_proxies entry '%s' covers a very broad address range (prefix /%s) — review whether such broad proxy trust is intended") .build(); + + /** + * The management interface resolved to plain HTTP at startup. + *

+ * This reports the observed effective state, not a declared intention: the audit + * inspects what the management listener actually resolved to, so the warning cannot drift + * away from reality when the activation route changes. The management interface has exactly + * one port, so the downgrade takes health and metrics in their entirety — there is no + * simultaneous HTTPS listener, and every consumer probing that port over HTTPS breaks. + *

+ * It is a {@code WARN} and never a boot refusal: a plain-HTTP management port behind a + * trusted network boundary is a legitimate deployment, and blocking it would be wrong. The + * template names the port only; it must never carry certificate paths or bucket contents. + *

+ * The remedy names both doors onto the downgrade, because the audit observes the + * effective state and cannot tell which one was used: the neutral {@code gateway.yaml} key and + * the deployment environment variable land on the same Quarkus key (ADR-0025). + */ + public static final LogRecord MANAGEMENT_PLAIN_HTTP = LogRecordModel.builder() + .prefix(PREFIX) + .identifier(115) + .template("Management interface is serving PLAIN HTTP on port %s — health and metrics are unencrypted and every HTTPS consumer of that port will fail. Expose it only behind a trusted boundary; restore the HTTPS default by setting management.tls.enabled back to true in gateway.yaml, or by removing a QUARKUS_MANAGEMENT_TLS_CONFIGURATION_NAME override supplied by the deployment") + .build(); } /** diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/config/NeutralTlsConfigSource.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/config/NeutralTlsConfigSource.java new file mode 100644 index 00000000..355f9b6a --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/config/NeutralTlsConfigSource.java @@ -0,0 +1,315 @@ +/* + * Copyright © 2026 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.gateway.config; + +import java.io.Reader; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + + +import de.cuioss.tools.logging.CuiLogger; + +import org.eclipse.microprofile.config.spi.ConfigSource; +import org.eclipse.microprofile.config.spi.ConfigSourceProvider; +import org.jspecify.annotations.Nullable; +import org.yaml.snakeyaml.LoaderOptions; +import org.yaml.snakeyaml.Yaml; + +/** + * Projects the neutral {@code management} policy block of {@code gateway.yaml} onto the + * {@code quarkus.*} key that no other seam can reach. + *

+ * The sibling top-level {@code tls} block is deliberately not read here. It governs the + * terminated listener, which has a live CDI seam + * ({@link de.cuioss.sheriff.gateway.tls.TlsServerCustomizer}) able to observe and overwrite the SSL + * options Quarkus actually built — a strictly better instrument than projecting configuration blind. + * Parsing it here as well would be a second reader of a block this source has no key to emit for. + *

+ * Why a {@code ConfigSource} at all. The terminated main HTTPS listener is + * configured through {@code HttpServerOptionsCustomizer}, which Quarkus enumerates from the CDI + * container after building the SSL options — that is the seam + * {@link de.cuioss.sheriff.gateway.tls.TlsServerCustomizer} uses. The management interface + * never enumerates customizers, so that seam is unavailable there; the only remaining lever on the + * management listener is the configuration the recorder reads. Hence this source. + *

+ * Why the ordinal must beat the environment. The ordinal is deliberately above the + * environment-variable source's 300, because a policy this source projects has to win over the same + * knob supplied as a deployment environment variable — otherwise a policy named in + * {@code gateway.yaml} would silently not take effect. That is safe only because of the + * policy-keys-only constraint below: no deployment-bound knob is ever projected, so nothing an + * operator sets in the environment can be outranked by surprise. + *

+ * Hard constraint — never project {@code quarkus.tls.key-store.*} (or any other key + * material into the DEFAULT TLS registry bucket). {@code HttpServerOptionsUtils}' + * management-interface lookup falls back to {@code registry.getDefault()} when + * {@code quarkus.management.tls-configuration-name} is absent, and it takes that fallback only when + * the default bucket actually carries key material (upstream quarkus issue 43380). This project's + * default bucket is empty — the server certificate arrives through + * {@code quarkus.http.ssl.certificate.*} — so management TLS today is exactly what + * {@code quarkus.management.ssl.certificate.*} says it is. Populating the default bucket from here + * would silently switch the management interface to HTTPS through a path no configuration file + * mentions. + *

+ * Why a second, lower-ordinal source is forbidden. Outranking the environment for + * every key would be an operator surprise, and the obvious fix — a second source at a lower + * ordinal for the keys the environment should still win — is explicitly rejected by ADR-0025. The + * constraint that makes the split unnecessary is that this source projects policy keys only: + * it never projects a port, never projects trust material, and never projects key material, so no + * deployment-bound knob can be outranked in the first place. A single ordinal is therefore both + * sufficient and safer. + *

+ * Why it cannot read {@link de.cuioss.sheriff.gateway.config.model.GatewayConfig}. A + * {@code ConfigSource} is instantiated while the configuration system itself is being built, long + * before CDI exists — so the bound model, its loader, and its validator are all unavailable. This + * class therefore re-reads {@code gateway.yaml} directly with SnakeYAML and parses only the one + * policy block it projects from. That is a deliberate, bounded duplication of a small slice of + * parsing, not a second configuration pipeline: the authoritative parse, schema validation and + * semantic validation still happen once, later, in {@code ConfigLoader} and {@code ConfigValidator}. + * A malformed or absent document is silently ignored here, because failing the boot is that pipeline's + * job and doing it twice would produce a worse, earlier, context-free error. + *

+ * What is projected. Exactly one key, and only when {@code gateway.yaml} declares + * {@code management.tls.enabled: false}: {@code quarkus.management.tls-configuration-name} is set to + * the key-less {@value #PLAIN_MANAGEMENT_BUCKET} bucket the shipped {@code application.properties} + * declares unconditionally. Selecting a bucket that carries no key material is the disable — + * the named lookup resolves, the absent key material is null-guarded rather than throwing, and + * {@code VertxHttpRecorder.initializeManagementInterface} substitutes the plain options because + * {@code getKeyCertOptions()} is {@code null}. + *

+ * Why an absent or {@code true} value projects nothing. The no-projection is + * deliberate, not a gap. HTTPS on the management interface is the secure default and needs no key to + * express it, and projecting nothing is what keeps the deployment-level + * {@code QUARKUS_MANAGEMENT_TLS_CONFIGURATION_NAME} a working route onto the same Quarkus key. The + * neutral {@code gateway.yaml} door and the deployment door are therefore two routes onto ONE lever + * rather than two competing mechanisms with a precedence puzzle between them — which is only true for + * as long as the secure case stays silent (see ADR-0025). + * + * @author API Sheriff Team + * @since 1.0 + */ +public final class NeutralTlsConfigSource implements ConfigSource { + + /** + * The ordinal of this source. Above the environment-variable source's 300 so a policy named in + * {@code gateway.yaml} is not silently overridden by the same knob supplied as a deployment + * environment variable; see the class Javadoc for why a single ordinal above environment is both + * required and safe. + */ + public static final int ORDINAL = 350; + + /** + * The named TLS bucket the management opt-out selects. It carries no key material, and its + * emptiness IS the mechanism — never add key material to it, and never declare a DEFAULT + * {@code quarkus.tls.key-store.*} bucket (see the class Javadoc). + */ + public static final String PLAIN_MANAGEMENT_BUCKET = "plain-management"; + + /** + * The single Quarkus key this source ever projects. Runtime configuration, so the deployment can + * reach the same lever through {@code QUARKUS_MANAGEMENT_TLS_CONFIGURATION_NAME}. + */ + public static final String MANAGEMENT_TLS_CONFIGURATION_NAME = "quarkus.management.tls-configuration-name"; + + private static final String NAME = "NeutralTlsConfigSource[gateway.yaml]"; + private static final String MANAGEMENT_BLOCK = "management"; + private static final String TLS_BLOCK = "tls"; + private static final String ENABLED_KEY = "enabled"; + private static final String CONFIG_DIR_PROPERTY = "sheriff.config.dir"; + private static final String CONFIG_DIR_ENV = "SHERIFF_CONFIG_DIR"; + private static final String DEFAULT_CONFIG_DIR = "config"; + private static final String GATEWAY_FILE = "gateway.yaml"; + /** The only block this source ever reads — the neutral management policy block. */ + private static final List POLICY_BLOCKS = List.of(MANAGEMENT_BLOCK); + private static final int MAX_YAML_NESTING_DEPTH = 100; + private static final int MAX_YAML_ALIASES = 50; + private static final int MAX_YAML_STRING_LENGTH = 1024 * 1024; + + private static final CuiLogger LOGGER = new CuiLogger(NeutralTlsConfigSource.class); + + private final Map projected; + + /** + * Reads {@code gateway.yaml} from the resolved configuration directory and computes the projected + * key set once. The projection is immutable for the lifetime of the source, matching the + * gateway's read-once-at-startup configuration model. + */ + public NeutralTlsConfigSource() { + this(resolveConfigDir()); + } + + /** + * Creates a source reading {@code gateway.yaml} from an explicit directory. + * + * @param configDir the directory holding {@code gateway.yaml} + */ + public NeutralTlsConfigSource(Path configDir) { + this.projected = project(readPolicyBlocks(configDir)); + } + + @Override + public int getOrdinal() { + return ORDINAL; + } + + @Override + public String getName() { + return NAME; + } + + @Override + public Set getPropertyNames() { + return projected.keySet(); + } + + @Override + public @Nullable String getValue(String propertyName) { + return projected.get(propertyName); + } + + @Override + public Map getProperties() { + return projected; + } + + /** + * Computes the keys this source projects from the parsed policy blocks. + *

+ * The projection is deliberately one-sided: only {@code management.tls.enabled: false} produces a + * key. Absent, {@code true}, and every shape the schema will later reject all project nothing, so + * the secure default is expressed by silence and the deployment keeps its own route onto + * {@value #MANAGEMENT_TLS_CONFIGURATION_NAME}. + */ + private static Map project(@Nullable Map policyBlocks) { + if (policyBlocks == null || !managementTlsDisabled(policyBlocks)) { + return Map.of(); + } + LOGGER.debug("gateway.yaml declares %s.%s.%s=false — selecting the key-less '%s' TLS bucket", + MANAGEMENT_BLOCK, TLS_BLOCK, ENABLED_KEY, PLAIN_MANAGEMENT_BUCKET); + return Map.of(MANAGEMENT_TLS_CONFIGURATION_NAME, PLAIN_MANAGEMENT_BUCKET); + } + + /** + * Answers whether the document explicitly declares the management interface off TLS. + *

+ * Every step is shape-guarded because this parse runs before the schema validates the + * document: a block that is not a map, or an {@code enabled} that is not a boolean, is a document + * {@code ConfigLoader} will reject moments later with a located error. Reading it as "not + * explicitly disabled" keeps TLS on in the meantime, which is the fail-safe direction. + */ + private static boolean managementTlsDisabled(Map policyBlocks) { + return policyBlocks.get(MANAGEMENT_BLOCK) instanceof Map management + && management.get(TLS_BLOCK) instanceof Map tls + && Boolean.FALSE.equals(tls.get(ENABLED_KEY)); + } + + /** + * Parses {@code gateway.yaml} and returns only the {@code management} policy block. + * Returns {@code null} when the document is absent or cannot be parsed — the authoritative + * loader reports those failures with full context, so this early pass stays silent. + */ + private static @Nullable Map readPolicyBlocks(Path configDir) { + Path gateway = configDir.resolve(GATEWAY_FILE); + if (!Files.isRegularFile(gateway)) { + LOGGER.debug("No %s under %s — projecting no key", GATEWAY_FILE, configDir); + return null; + } + try (Reader reader = Files.newBufferedReader(gateway)) { + Object document = new Yaml(hardenedLoaderOptions()).load(reader); + if (!(document instanceof Map root)) { + return null; + } + return policyBlocksOf(root); + // Deliberately broad and deliberately silent at DEBUG: any parse or I/O failure here is + // re-encountered by ConfigLoader moments later, which reports it as a collected, + // located ConfigError and fails the boot. Rethrowing would replace that precise + // diagnostic with a context-free failure from inside config-system construction. + // cui-rewrite:disable InvalidExceptionUsageRecipe + } catch (Exception e) { + LOGGER.debug(e, "Cannot pre-read %s — projecting no key", gateway); + return null; + } + } + + private static Map policyBlocksOf(Map root) { + Map blocks = new HashMap<>(POLICY_BLOCKS.size()); + for (String block : POLICY_BLOCKS) { + if (root.get(block) instanceof Map value) { + blocks.put(block, value); + } + } + return blocks; + } + + /** + * Resolves the configuration directory the same way the boot pipeline does, but without the + * configuration system — which is still under construction when this source is created. The + * system property and the environment variable are read directly; the default matches + * {@code application.properties}' {@code sheriff.config.dir=config}. + */ + private static Path resolveConfigDir() { + String configured = System.getProperty(CONFIG_DIR_PROPERTY); + if (configured == null || configured.isBlank()) { + configured = System.getenv(CONFIG_DIR_ENV); + } + if (configured == null || configured.isBlank()) { + configured = DEFAULT_CONFIG_DIR; + } + return Path.of(configured); + } + + /** + * The same YAML expansion / nesting bomb caps the authoritative loader applies, so this earlier + * pass cannot be turned into a denial-of-service vector by a document the loader would reject. + */ + private static LoaderOptions hardenedLoaderOptions() { + LoaderOptions options = new LoaderOptions(); + options.setMaxAliasesForCollections(MAX_YAML_ALIASES); + options.setNestingDepthLimit(MAX_YAML_NESTING_DEPTH); + options.setCodePointLimit(MAX_YAML_STRING_LENGTH); + return options; + } + + /** + * Discovers {@link NeutralTlsConfigSource} for both the JVM and the native image. + *

+ * Registration goes through a provider rather than listing the source directly under + * {@code META-INF/services/org.eclipse.microprofile.config.spi.ConfigSource} so the source is + * constructed when the configuration system asks for it — at which point the + * {@code sheriff.config.dir} system property or environment variable it resolves is already + * observable. + * + * @author API Sheriff Team + * @since 1.0 + */ + public static final class Provider implements ConfigSourceProvider { + + /** + * Creates the provider. Invoked reflectively by the {@link java.util.ServiceLoader}. + */ + public Provider() { + // ServiceLoader requires an accessible no-argument constructor. + } + + @Override + public Iterable getConfigSources(ClassLoader forClassLoader) { + return List.of(new NeutralTlsConfigSource()); + } + } +} diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/config/load/ConfigLoader.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/config/load/ConfigLoader.java index 496ec5de..1f23dc00 100644 --- a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/config/load/ConfigLoader.java +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/config/load/ConfigLoader.java @@ -58,6 +58,7 @@ import com.networknt.schema.InputFormat; import com.networknt.schema.Schema; import com.networknt.schema.SchemaRegistry; +import com.networknt.schema.SchemaRegistryConfig; import com.networknt.schema.SpecificationVersion; import org.jspecify.annotations.Nullable; import org.yaml.snakeyaml.LoaderOptions; @@ -103,6 +104,15 @@ public final class ConfigLoader { private static final int MAX_YAML_NESTING_DEPTH = 100; private static final int MAX_YAML_ALIASES = 50; private static final int MAX_YAML_STRING_LENGTH = 1024 * 1024; + /** + * Enables the validator's {@code errorMessage} extension, which is OFF unless the keyword is + * named on the registry configuration. Naming it lets a schema attach an operator-facing sentence + * to a specific subschema, so a deliberate rejection reports why and where the knob + * actually lives at its own JSON Pointer, rather than surfacing as a generic unknown-key + * message raised against the parent object. {@code gateway.schema.json}'s {@code management.port} + * refusal is the first use. + */ + private static final String ERROR_MESSAGE_KEYWORD = "errorMessage"; private static final Pattern INTEGER = Pattern.compile("-?\\d+"); private static final List SECRET_POINTERS = List.of( "/oidc/client_secret", "/oidc/session/encryption_key", "/oidc/session/previous_key"); @@ -125,7 +135,10 @@ public ConfigLoader(Path configDir, EnvSecretResolver secretResolver) { this.configDir = Objects.requireNonNull(configDir, "configDir"); this.secretResolver = Objects.requireNonNull(secretResolver, "secretResolver"); this.mapper = buildMapper(); - SchemaRegistry registry = SchemaRegistry.withDefaultDialect(SpecificationVersion.DRAFT_2020_12); + SchemaRegistry registry = SchemaRegistry.withDefaultDialect(SpecificationVersion.DRAFT_2020_12, + builder -> builder.schemaRegistryConfig(SchemaRegistryConfig.builder() + .errorMessageKeyword(ERROR_MESSAGE_KEYWORD) + .build())); this.gatewaySchema = loadSchema(registry, "/schema/gateway.schema.json"); this.endpointSchema = loadSchema(registry, "/schema/endpoint.schema.json"); } diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/config/model/GatewayConfig.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/config/model/GatewayConfig.java index 235c64ff..c2ef1b58 100644 --- a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/config/model/GatewayConfig.java +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/config/model/GatewayConfig.java @@ -38,6 +38,9 @@ * by the validator) * @param metadata the audit-stamp metadata, empty when omitted * @param tls the TLS settings, empty when omitted + * @param management the management-interface policy settings, empty when omitted. Carries TLS + * policy only and declares no port — the management port stays + * deployment-bound at {@code quarkus.management.port} (ADR-0025) * @param securityHeaders the response-header middleware settings, empty when * omitted * @param securityDefaults the global security-filter baseline, empty when omitted @@ -55,6 +58,7 @@ */ @Builder public record GatewayConfig(int version, Optional metadata, Optional tls, +Optional management, Optional securityHeaders, Optional securityDefaults, List allowedMethods, Map anchors, Optional upstreamDefaults, Optional forwarded, Optional tokenValidation, Optional oidc, @@ -68,6 +72,7 @@ public record GatewayConfig(int version, Optional metadata, Optionalpolicy surface for the management interface (health and metrics on their own port). + *

+ * This block declares no {@code port} component, deliberately (ADR-0025). The + * ruling that governs the whole server-TLS surface classifies every knob as TLS policy, + * deployment-bound, or build-time: policy is named neutrally here, while ports and + * trust material stay deployment-supplied. The management port therefore remains the deployment's + * knob at its existing {@code quarkus.management.port} default of 9000, overridable per deployment + * by {@code QUARKUS_MANAGEMENT_PORT}. + *

+ * The omission is structural rather than cosmetic. A {@code management.port} declared here could only + * reach Quarkus through {@code NeutralTlsConfigSource}, whose ordinal is deliberately above the + * environment source's 300 so a policy named in {@code gateway.yaml} is not silently overridden. A + * port projected at that ordinal would in turn silently override {@code QUARKUS_MANAGEMENT_PORT} — + * exactly the operator surprise the ruling rules out — and the only way to let the environment win + * would be a second, lower-ordinal source, which the same ruling forbids. {@code gateway.schema.json} + * therefore declares {@code management.port} solely in order to refuse it, with a message naming the + * property that does own the port. + * + * @param tls the management-interface TLS policy, empty when omitted + * @author API Sheriff Team + * @since 1.0 + */ +@Builder +public record ManagementConfig(Optional tls) { + + /** + * Canonical constructor normalizing an absent {@code tls} block to {@link Optional#empty()}. + */ + public ManagementConfig { + tls = Objects.requireNonNullElse(tls, Optional.empty()); + } + + /** + * TLS policy for the management interface. + *

+ * Unlike the main HTTP listener, the management interface has exactly one port — + * Quarkus' management configuration declares no {@code ssl-port} and no + * {@code insecure-requests} key — so this toggle governs the whole management surface. There is + * no simultaneous HTTPS listener to fall back to: taking management off TLS takes health and + * metrics to plain HTTP in their entirety, which is why the downgrade is audited with a loud + * {@code WARN} at startup rather than passing silently. + *

+ * {@code true} is the secure default and the shipped posture. + * + * @param enabled whether the management interface serves over TLS + * @author API Sheriff Team + * @since 1.0 + */ + @Builder + public record ManagementTls(boolean enabled) { + } +} diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/config/model/TlsConfig.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/config/model/TlsConfig.java index 51d7c56e..5068059c 100644 --- a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/config/model/TlsConfig.java +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/config/model/TlsConfig.java @@ -26,6 +26,8 @@ * The global {@code tls} block of {@code gateway.yaml}. * * @param minVersion the minimum negotiated TLS version, empty when omitted + * @param cipherSuites the terminated listener's cipher-suite allowlist, empty + * when omitted * @param alpn the advertised ALPN protocols, empty when omitted * @param passthroughSni a map of SNI hostname to topology alias relayed at L4 * without decryption, empty when omitted @@ -34,8 +36,8 @@ * @since 1.0 */ @Builder -public record TlsConfig(Optional minVersion, List alpn, Map passthroughSni, -Optional mtls) { +public record TlsConfig(Optional minVersion, List cipherSuites, List alpn, +Map passthroughSni, Optional mtls) { /** * Canonical constructor defensively copying collections and normalizing absent @@ -43,6 +45,7 @@ public record TlsConfig(Optional minVersion, List alpn, Map + * It audits the observed effective state, never a declared intention. The trigger is + * the TLS material the management listener really resolved — the same question + * {@code VertxHttpRecorder.initializeManagementInterface} asks when it decides between the SSL and + * the plain options — rather than any single configuration key that is believed to cause a downgrade. + * A configuration key can be renamed, superseded, or silently ignored, and an audit keyed on one + * would then report a comfortable fiction. The resolved key material cannot: if there is none, the + * port is plain, whatever route got it there. + *

+ * Why a WARN and never a boot refusal. A plain-HTTP management port behind a trusted + * network boundary is a legitimate deployment, and the gateway must not block it. But because Quarkus' + * management configuration declares no {@code ssl-port} and no {@code insecure-requests} key, the + * management interface has exactly one port: the downgrade takes health and metrics in their + * entirety, and every consumer probing that port over HTTPS breaks. That is worth saying loudly once, + * at boot, in the log an operator actually reads. + *

+ * Lazy-proxy hazard (lesson 2026-07-20-18-002). A normal-scoped observer bean that + * merely holds an injected collaborator gets a lazy CDI proxy that is never touched, so the + * check silently never runs while unit tests calling the method directly stay green. The observer + * here therefore actively invokes {@link #auditManagementTls()}, which in turn actively calls into the + * injected {@link TlsConfigurationRegistry} — and the accompanying test asserts the warning fires + * through the real startup-event path, not by direct invocation. + * + * @author API Sheriff Team + * @since 1.0 + */ +@ApplicationScoped +public class ManagementPlainHttpAudit { + + private static final CuiLogger LOGGER = new CuiLogger(ManagementPlainHttpAudit.class); + + private final TlsConfigurationRegistry registry; + private final Optional tlsConfigurationName; + private final boolean managementCertificateConfigured; + private final int managementPort; + + /** + * @param registry the live TLS registry, resolved exactly as the management recorder + * resolves it + * @param tlsConfigurationName the selected named TLS bucket, empty when the deployment selects + * none + * @param certificateFiles the management certificate chain, empty when the deployment + * supplies none + * @param managementPort the management port, reported in the warning + */ + @Inject + public ManagementPlainHttpAudit( + TlsConfigurationRegistry registry, + @ConfigProperty(name = "quarkus.management.tls-configuration-name") Optional tlsConfigurationName, + @ConfigProperty(name = "quarkus.management.ssl.certificate.files") Optional> certificateFiles, + @ConfigProperty(name = "quarkus.management.port", defaultValue = "9000") int managementPort) { + this.registry = Objects.requireNonNull(registry, "registry"); + this.tlsConfigurationName = Objects.requireNonNull(tlsConfigurationName, "tlsConfigurationName"); + this.managementCertificateConfigured = certificateFiles.filter(files -> !files.isEmpty()).isPresent(); + this.managementPort = managementPort; + } + + /** + * Runs the audit on the real startup event. + *

+ * The observer actively invokes {@link #auditManagementTls()} rather than delegating to a + * collaborator's lifecycle callback; see the class documentation for the lazy-proxy hazard this + * defeats. + * + * @param event the Quarkus startup event + */ + void onStartup(@Observes StartupEvent event) { + auditManagementTls(); + } + + /** + * Resolves the management interface's effective TLS state and warns when it is plain HTTP. + * + * @return {@code true} when the management interface resolved to plain HTTP + */ + public boolean auditManagementTls() { + Optional resolved = TlsConfiguration.from(registry, tlsConfigurationName); + boolean plain = resolvesToPlainHttp(resolved, managementCertificateConfigured); + if (plain) { + LOGGER.warn(ConfigLogMessages.WARN.MANAGEMENT_PLAIN_HTTP, managementPort); + } else { + LOGGER.debug("Management interface resolved to HTTPS on port %s", managementPort); + } + return plain; + } + + /** + * Decides whether the resolved TLS state leaves the management listener on plain HTTP, mirroring + * the recorder's own test: a selected TLS configuration that carries no key material replaces the + * deployment's certificate rather than adding to it, so the listener ends up with no key/cert and + * the recorder swaps in the plain options. + * + * @param resolved the TLS configuration the management interface resolved to, empty + * when none applies + * @param certificateConfigured whether {@code quarkus.management.ssl.certificate.files} supplies + * a chain + * @return {@code true} when no key material reaches the management listener + */ + static boolean resolvesToPlainHttp(Optional resolved, boolean certificateConfigured) { + if (resolved.isPresent()) { + return resolved.get().getKeyStoreOptions() == null; + } + return !certificateConfigured; + } +} diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/tls/TlsServerCustomizer.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/tls/TlsServerCustomizer.java new file mode 100644 index 00000000..d7a13934 --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/tls/TlsServerCustomizer.java @@ -0,0 +1,220 @@ +/* + * Copyright © 2026 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.gateway.tls; + +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; +import javax.net.ssl.SSLContext; + + +import de.cuioss.sheriff.gateway.config.model.GatewayConfig; +import de.cuioss.sheriff.gateway.config.model.TlsConfig; +import de.cuioss.tools.logging.CuiLogger; + +import io.quarkus.vertx.http.HttpServerOptionsCustomizer; +import io.vertx.core.http.HttpServerOptions; +import io.vertx.core.http.HttpVersion; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; + +/** + * Maps the resolved global {@code tls} block of {@code gateway.yaml} onto the terminated Quarkus + * HTTPS listener: {@code tls.min_version} becomes the listener's enabled protocol set, + * {@code tls.cipher_suites} its cipher allowlist, and {@code tls.alpn} its advertised ALPN + * protocols. {@code gateway.yaml} is thereby the single effective source of terminated-listener TLS + * policy — the raw {@code quarkus.tls.*} keys that previously declared the same policy never reached + * the listener and have been deleted (ADR-0025). + *

+ * Every mapping is a no-op when its key is absent, so an omitted {@code tls} block — or an omitted + * individual key — leaves the corresponding Quarkus default untouched. + *

+ * All three knobs are fail-closed: an unrecognized {@code min_version}, an unrecognized + * {@code alpn} identifier and a {@code cipher_suites} entry the JDK does not support each abort the + * boot with an actionable {@link IllegalStateException} rather than being silently ignored. The + * cipher check matters most, because an unsupported suite name is not rejected anywhere + * downstream: Vert.x binds the listener happily and the defect only surfaces as an + * {@code SSLHandshakeException} on every client connection — a listener that accepts TCP but can + * never complete a handshake, or, for a partly mistyped list, a silently narrowed allowlist. + *

+ * This customizer governs the terminated listener only. The SNI front listener + * ({@link SniFrontListener}) is a raw Vert.x {@code NetServer} that terminates nothing — it splits + * passthrough connections off at L4 before any handshake — so it is unaffected by this policy + * (ADR-0017). Client-auth on the terminated listener is owned by the sibling + * {@link MtlsServerCustomizer} and is deliberately not touched here. + * + * @author API Sheriff Team + * @since 1.0 + */ +@ApplicationScoped +public class TlsServerCustomizer implements HttpServerOptionsCustomizer { + + private static final CuiLogger LOGGER = new CuiLogger(TlsServerCustomizer.class); + + private static final String TLS_V1_2 = "TLSv1.2"; + private static final String TLS_V1_3 = "TLSv1.3"; + + private final GatewayConfig gatewayConfig; + + /** + * @param gatewayConfig the bound global gateway document (source of the {@code tls} block) + */ + @Inject + public TlsServerCustomizer(GatewayConfig gatewayConfig) { + this.gatewayConfig = Objects.requireNonNull(gatewayConfig, "gatewayConfig"); + } + + /** + * Applies the declared TLS floor, cipher allowlist and ALPN protocol list to the terminated + * HTTPS listener; each mapping is skipped when its {@code gateway.yaml} key is absent. + * + * @param options the HTTPS server options Quarkus is about to start the terminated listener with + */ + @Override + public void customizeHttpsServer(HttpServerOptions options) { + Optional tls = gatewayConfig.tls(); + if (tls.isEmpty()) { + return; + } + TlsConfig config = tls.get(); + applyMinVersion(config, options); + applyCipherSuites(config, options); + applyAlpn(config, options); + } + + /** + * Maps the declared floor onto the enabled protocol set: a {@code 1.2} floor enables TLSv1.2 and + * TLSv1.3, a {@code 1.3} floor enables TLSv1.3 only. + */ + private static void applyMinVersion(TlsConfig config, HttpServerOptions options) { + Optional minVersion = config.minVersion(); + if (minVersion.isEmpty()) { + return; + } + Set enabledProtocols = switch (minVersion.get()) { + case "1.2" -> Set.of(TLS_V1_2, TLS_V1_3); + case "1.3" -> Set.of(TLS_V1_3); + default -> throw new IllegalStateException( + "Refusing to start — unsupported tls.min_version '%s'; expected '1.2' or '1.3'" + .formatted(minVersion.get())); + }; + options.setEnabledSecureTransportProtocols(enabledProtocols); + LOGGER.debug("Terminated HTTPS listener TLS floor '%s' enables protocols %s", minVersion.get(), + enabledProtocols); + } + + /** + * Adds each declared cipher suite to the listener's allowlist; an empty list leaves the Quarkus + * default suite selection untouched. Every declared suite is first checked against the JDK's own + * supported set, because nothing downstream performs that check: an unsupported name binds a + * listener that fails every handshake instead of refusing to start. + */ + private static void applyCipherSuites(TlsConfig config, HttpServerOptions options) { + List cipherSuites = config.cipherSuites(); + if (cipherSuites.isEmpty()) { + return; + } + Set supported = supportedCipherSuites(); + for (String suite : cipherSuites) { + if (!supported.contains(suite)) { + List closest = closestSupported(suite, supported); + throw new IllegalStateException( + "Refusing to start — unsupported tls.cipher_suites entry '%s'; this JDK supports no such suite%s" + .formatted(suite, + closest.isEmpty() ? "" : ", closest supported: " + closest)); + } + } + cipherSuites.forEach(options::addEnabledCipherSuite); + LOGGER.debug("Terminated HTTPS listener cipher-suite allowlist: %s", cipherSuites); + } + + /** + * The cipher suites the platform's default {@link SSLContext} can actually negotiate — the same + * set the {@code SSLEngine} backing this listener is built from, and therefore the only correct + * authority for validating a declared allowlist. Resolved per boot rather than cached in a static + * initializer so a native-image build cannot bake a build-host value into the executable. + */ + private static Set supportedCipherSuites() { + try { + return Set.of(SSLContext.getDefault().getSupportedSSLParameters().getCipherSuites()); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException( + "Refusing to start — the platform default SSLContext is unavailable, so the declared " + + "tls.cipher_suites allowlist cannot be validated", + e); + } + } + + /** + * Ranks the supported suites by how many underscore-separated tokens they share with the rejected + * name, returning the best three. Suites sharing only the universal {@code TLS} token are dropped, + * so a wholly unrelated string yields no misleading suggestion at all. + */ + private static List closestSupported(String declared, Set supported) { + Set declaredTokens = Arrays.stream(declared.toUpperCase(Locale.ROOT).split("_")) + .collect(Collectors.toSet()); + return supported.stream() + .filter(candidate -> sharedTokens(candidate, declaredTokens) > 1) + .sorted(Comparator.comparingLong((String candidate) -> sharedTokens(candidate, declaredTokens)) + .reversed().thenComparing(Comparator.naturalOrder())) + .limit(3) + .toList(); + } + + private static long sharedTokens(String candidate, Set declaredTokens) { + return Arrays.stream(candidate.split("_")).distinct().filter(declaredTokens::contains).count(); + } + + /** + * Enables ALPN and advertises the declared protocols; an empty list leaves ALPN as Quarkus + * configured it. + */ + private static void applyAlpn(TlsConfig config, HttpServerOptions options) { + List alpn = config.alpn(); + if (alpn.isEmpty()) { + return; + } + List alpnVersions = new ArrayList<>(alpn.size()); + for (String protocol : alpn) { + alpnVersions.add(toHttpVersion(protocol)); + } + options.setUseAlpn(true); + options.setAlpnVersions(alpnVersions); + LOGGER.debug("Terminated HTTPS listener ALPN protocols: %s", alpn); + } + + /** + * Resolves an ALPN protocol identifier to its Vert.x {@link HttpVersion}. An unrecognized + * identifier aborts the boot rather than being silently dropped from the advertised set. + */ + private static HttpVersion toHttpVersion(String alpnProtocol) { + return switch (alpnProtocol) { + case "h2" -> HttpVersion.HTTP_2; + case "http/1.1" -> HttpVersion.HTTP_1_1; + case "http/1.0" -> HttpVersion.HTTP_1_0; + default -> throw new IllegalStateException( + "Refusing to start — unsupported tls.alpn protocol '%s'; expected 'h2', 'http/1.1' or 'http/1.0'" + .formatted(alpnProtocol)); + }; + } +} diff --git a/api-sheriff/src/main/resources/META-INF/services/org.eclipse.microprofile.config.spi.ConfigSourceProvider b/api-sheriff/src/main/resources/META-INF/services/org.eclipse.microprofile.config.spi.ConfigSourceProvider new file mode 100644 index 00000000..3ba9e7e3 --- /dev/null +++ b/api-sheriff/src/main/resources/META-INF/services/org.eclipse.microprofile.config.spi.ConfigSourceProvider @@ -0,0 +1 @@ +de.cuioss.sheriff.gateway.config.NeutralTlsConfigSource$Provider diff --git a/api-sheriff/src/main/resources/application.properties b/api-sheriff/src/main/resources/application.properties index 214c56bc..fbed78c0 100644 --- a/api-sheriff/src/main/resources/application.properties +++ b/api-sheriff/src/main/resources/application.properties @@ -4,6 +4,14 @@ quarkus.application.name=api-sheriff # HTTP/HTTPS Configuration +# +# quarkus.http.ssl-port and quarkus.http.insecure-requests stay here deliberately: they are +# deployment-bound PORT and EXPOSURE knobs, not TLS policy, and are therefore NOT promoted into +# gateway.yaml. ADR-0025 states the boundary rule — gateway.yaml names TLS *policy* in neutral terms, +# while ports and trust material stay deployment-supplied. The ADR-0017 SNI passthrough topology +# depends on exactly this: a deployment that enables tls.passthrough_sni overrides +# QUARKUS_HTTP_SSL_PORT to move the terminated listener to the internal loopback port (8444), which a +# gateway.yaml-owned port could not express. quarkus.http.port=8080 quarkus.http.ssl-port=8443 quarkus.http.insecure-requests=redirect @@ -24,21 +32,69 @@ quarkus.http.insecure-requests=redirect # 64M == 67108864 bytes, matching the largest declared max_body_bytes in the shipped topology. quarkus.http.limits.max-body-size=64M -# Management interface — health/metrics on a separate port (9000), served over HTTPS. +# Management interface — health/metrics on a separate port, served over HTTPS by default. # Follows the Keycloak pattern of splitting operations off the application port: application on # 8443, operations on 9000. Unlike quarkus.http, Quarkus' ManagementConfig declares NO ssl-port and # NO insecure-requests key — the management interface has exactly ONE port, so supplying -# quarkus.management.ssl.certificate.* converts port 9000 itself to HTTPS. There is no simultaneous -# plain-HTTP management listener; every consumer of 9000 must speak TLS. -# The certificate paths are deliberately NOT set here: they are container paths +# quarkus.management.ssl.certificate.* converts that port itself to HTTPS and there is no +# simultaneous plain-HTTP management listener. +# +# HTTPS on the management interface is the DEFAULT and stays the default. Management TLS POLICY is +# named neutrally in gateway.yaml's `management` block; do not add a raw quarkus.management.ssl.* +# policy key here (ADR-0025). +# +# Taking management back to plain HTTP is a Quarkus-native operation, not a bespoke one: point +# quarkus.management.tls-configuration-name (environment QUARKUS_MANAGEMENT_TLS_CONFIGURATION_NAME) +# at a named TLS bucket that carries no key material. The named lookup resolves, the key material is +# null-guarded, and VertxHttpRecorder.initializeManagementInterface swaps in the plain options. It is +# runtime configuration, so it works in JVM and native alike with no rebuild, and an unresolvable +# name fails loudly with a ConfigurationException naming the exact quarkus.tls. property. +# +# The management PORT is NOT part of the neutral block and stays deployment-bound: it remains +# quarkus.management.port (Quarkus default 9000), overridable per deployment by +# QUARKUS_MANAGEMENT_PORT. gateway.yaml names TLS policy in neutral terms while ports and trust +# material stay deployment-supplied; a `management.port` written in gateway.yaml is refused at boot +# with a message naming this property (ADR-0025). +# +# The certificate paths are likewise deliberately NOT set here: they are container paths # (/app/certificates/...) that would break every non-container run. The deployment supplies them via # QUARKUS_MANAGEMENT_SSL_CERTIFICATE_FILES / QUARKUS_MANAGEMENT_SSL_CERTIFICATE_KEY_FILES, exactly # as it already does for the main listener. quarkus.management.enabled=true -# TLS Configuration -quarkus.ssl.native=true -quarkus.tls.protocols=TLSv1.3,TLSv1.2 +# The key-less TLS bucket the plain-HTTP management opt-out selects. +# +# It is declared here UNCONDITIONALLY and in no profile. Two reasons, both structural. (1) Quarkus +# discovers quarkus.tls..* buckets as configuration MAP KEYS, and map-key discovery from a bare +# environment variable is not something this project relies on — declaring the bucket in the shipped +# properties file is what guarantees the name resolves in every runtime, JVM and native alike. +# (2) A %-profile-scoped declaration would be a profile branch in the shipped configuration surface, +# which the standing rule forbids. Only the SELECTION is per-deployment. +# +# The bucket is inert and trust-free BY CONSTRUCTION: it carries no key-store, no PEM, no trust +# anchors, and no passwords. `reload-period` is a scheduling knob with nothing to reload, chosen +# precisely because it declares the map key without declaring any material. That emptiness is the +# whole mechanism — selecting this bucket leaves getKeyCertOptions() null, so +# VertxHttpRecorder.initializeManagementInterface swaps in the plain-HTTP options. +# +# NEVER add key material to this bucket, and never declare a DEFAULT quarkus.tls.key-store.* bucket +# anywhere: the management interface falls back to the default registry bucket when no configuration +# name is selected, and takes that fallback only when the default bucket carries key material — so a +# populated default bucket would silently switch management to HTTPS through a path no configuration +# file mentions (upstream quarkus issue 43380). +quarkus.tls.plain-management.reload-period=24H + +# Terminated-listener TLS policy is single-sourced from gateway.yaml's `tls` block (min_version, +# cipher_suites, alpn) and bound onto the listener by tls/TlsServerCustomizer — there is no raw +# quarkus.tls.* policy key here any more. The keys that used to sit in this block +# (quarkus.tls.protocols, quarkus.tls.cipher-suites, quarkus.tls.alpn) never reached the listener: +# Quarkus consults the default TLS-registry configuration only when it carries KeyStoreOptions, i.e. +# only when the certificate is supplied via quarkus.tls.key-store.*. This gateway supplies its +# certificate through quarkus.http.ssl.certificate.* instead, so that guard always failed and the +# listener was built from quarkus.http.ssl.* — where no protocols and no cipher suites were declared. +# The declared TLS floor and cipher allowlist were therefore inert; they are now in force through +# gateway.yaml (ADR-0025). The build-time GraalVM knob quarkus.ssl.native moved to api-sheriff/pom.xml's +# native profile, which is the only place it can take effect. # Accept-time TLS edge — SNI passthrough split (tls/SniFrontListener, tls/TlsEdgeProducer). # The Vert.x front listener is started ONLY when gateway.yaml declares tls.passthrough_sni; when it @@ -55,11 +111,10 @@ sheriff.tls.internal-https-port=8444 # mTLS on terminated connections (tls/MtlsServerCustomizer). Client-auth is bound PROGRAMMATICALLY # from gateway.yaml's tls.mtls block: when tls.mtls.enabled is set the terminated HTTPS listener is # switched to require-and-verify a client certificate against the configured client_ca trust anchor -# (no HTTP fallback, no optional/want mode). This explicit default keeps client-auth OFF when -# tls.mtls is absent or disabled; passthrough connections are split off at L4 and never mTLS-checked. -quarkus.http.ssl.client-auth=none -quarkus.tls.cipher-suites=TLS_AES_256_GCM_SHA384,TLS_CHACHA20_POLY1305_SHA256,TLS_AES_128_GCM_SHA256,TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256 -quarkus.tls.alpn=true +# (no HTTP fallback, no optional/want mode). No quarkus.http.ssl.client-auth key is declared here: the +# customizer runs after createSslOptions and overwrites whatever that key had set, so gateway.yaml's +# tls.mtls is the single source — absent or disabled leaves client-auth off. Passthrough connections +# are split off at L4 and never mTLS-checked. # Native Image Configuration # The four BFF classes below each hold a `static final SecureRandom`. GraalVM initializes static diff --git a/api-sheriff/src/main/resources/schema/gateway.schema.json b/api-sheriff/src/main/resources/schema/gateway.schema.json index e4c7e50d..17749dde 100644 --- a/api-sheriff/src/main/resources/schema/gateway.schema.json +++ b/api-sheriff/src/main/resources/schema/gateway.schema.json @@ -106,6 +106,11 @@ "additionalProperties": false, "properties": { "min_version": { "type": "string", "enum": ["1.2", "1.3"] }, + "cipher_suites": { + "type": "array", + "description": "Cipher-suite allowlist applied to the terminated HTTPS listener. Omitted leaves the platform default suite selection in force.", + "items": { "type": "string" } + }, "alpn": { "type": "array", "items": { "type": "string" } }, "passthrough_sni": { "type": "object", @@ -123,6 +128,28 @@ } } }, + "management": { + "type": "object", + "additionalProperties": false, + "description": "Neutral policy block for the management interface (health and metrics on their own port). TLS POLICY only: the management PORT is deployment-bound and is deliberately not settable here, so the block declares 'port' solely to reject it with an actionable message (ADR-0025).", + "properties": { + "tls": { + "type": "object", + "additionalProperties": false, + "required": ["enabled"], + "properties": { + "enabled": { "type": "boolean" } + } + }, + "port": { + "not": {}, + "description": "Declared only to be refused. The always-failing subschema plus the 'errorMessage' extension puts the actionable sentence at the /management/port pointer, instead of the generic 'additionalProperties' rejection the parent object would otherwise raise.", + "errorMessage": { + "not": "the management port is deployment-bound and is not settable in gateway.yaml: set quarkus.management.port (environment QUARKUS_MANAGEMENT_PORT, default 9000) instead — see ADR-0025" + } + } + } + }, "security_headers": { "$ref": "#/$defs/securityHeaders" }, "security_defaults": { "type": "object", diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/config/NeutralTlsConfigSourceTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/config/NeutralTlsConfigSourceTest.java new file mode 100644 index 00000000..7b0f87b3 --- /dev/null +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/config/NeutralTlsConfigSourceTest.java @@ -0,0 +1,285 @@ +/* + * Copyright © 2026 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.gateway.config; + +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import java.util.Set; +import java.util.stream.StreamSupport; + +import io.smallrye.config.SmallRyeConfigBuilder; +import org.eclipse.microprofile.config.Config; +import org.eclipse.microprofile.config.spi.ConfigSource; +import org.jspecify.annotations.Nullable; +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.ValueSource; + +/** + * Tests for {@link NeutralTlsConfigSource}: the management TLS projection, the ordinal contract that + * lets a policy named in {@code gateway.yaml} outrank the same knob supplied as a deployment + * environment variable, the silent-degradation contract for an absent or malformed document, and the + * negative constraints that bound what the source is allowed to project. + *

+ * The projection is asserted through the real config-resolution path. The + * behaviour that matters is not what {@link NeutralTlsConfigSource#getValue(String)} returns in + * isolation but what the assembled {@link Config} resolves after ordinal arbitration — that is the + * question Quarkus actually asks the source. Calling the source's own accessor would leave a + * mechanism green while it was never exercised through the seam that uses it, which is the failure + * mode this whole plan exists to eliminate. Every projection assertion therefore builds a + * {@code SmallRyeConfig} and reads the value back through the {@code Config} API. + *

+ * The lower-ordinal stand-in is a matched control, not scenery. A no-projection + * assertion made against an otherwise-empty configuration proves nothing: the value would be absent + * whether or not the source declined to project it. Each no-projection case therefore runs against a + * competing value supplied at the environment source's ordinal, so "the deployment value survives" is + * a positive observation rather than the absence of any observation at all — and the disabled case + * runs against the same competitor, so "the projection wins" is likewise observed rather than assumed. + */ +class NeutralTlsConfigSourceTest { + + private static final String MANAGEMENT_TLS_CONFIGURATION_NAME = + NeutralTlsConfigSource.MANAGEMENT_TLS_CONFIGURATION_NAME; + private static final String PLAIN_MANAGEMENT = NeutralTlsConfigSource.PLAIN_MANAGEMENT_BUCKET; + + /** A value only a deployment could have supplied, so its survival is unambiguous. */ + private static final String DEPLOYMENT_SUPPLIED = "deployment-supplied-bucket"; + + /** The ordinal SmallRye gives the environment-variable source — the precedence being contested. */ + private static final int ENVIRONMENT_ORDINAL = 300; + + private static final String MANAGEMENT_TLS_DISABLED = """ + version: 1 + tls: + min_version: "1.3" + management: + tls: + enabled: false + """; + + private static final String MANAGEMENT_TLS_ENABLED = """ + version: 1 + tls: + min_version: "1.3" + management: + tls: + enabled: true + """; + + private static final String NO_MANAGEMENT_BLOCK = """ + version: 1 + tls: + min_version: "1.3" + """; + + @TempDir + Path configDir; + + private void writeGateway(String content) throws IOException { + Files.writeString(configDir.resolve("gateway.yaml"), content); + } + + /** + * Resolves {@link #MANAGEMENT_TLS_CONFIGURATION_NAME} the way Quarkus does — through an assembled + * {@link Config} — with a competing deployment value present at the environment source's ordinal. + */ + private @Nullable String resolveAgainstDeploymentValue() { + Config config = new SmallRyeConfigBuilder() + .withSources(new NeutralTlsConfigSource(configDir)) + .withSources(new StandInEnvironmentSource( + Map.of(MANAGEMENT_TLS_CONFIGURATION_NAME, DEPLOYMENT_SUPPLIED))) + .build(); + return config.getOptionalValue(MANAGEMENT_TLS_CONFIGURATION_NAME, String.class).orElse(null); + } + + @Test + @DisplayName("The ordinal outranks the environment-variable source") + void ordinalOutranksTheEnvironmentVariableSource() { + assertTrue(NeutralTlsConfigSource.ORDINAL > ENVIRONMENT_ORDINAL, + "an ordinal at or below the environment source's 300 would let a deployment variable " + + "silently override a policy named in gateway.yaml"); + } + + @Test + @DisplayName("management.tls.enabled=false selects the key-less bucket through the resolved Config") + void disabledManagementTlsSelectsThePlainBucketThroughTheResolvedConfig() throws Exception { + writeGateway(MANAGEMENT_TLS_DISABLED); + + assertEquals(PLAIN_MANAGEMENT, resolveAgainstDeploymentValue(), + "the neutral opt-out must reach the management listener through the config-resolution " + + "path and must outrank the deployment value — that precedence is the entire " + + "reason the ordinal sits above 300"); + } + + @Test + @DisplayName("management.tls.enabled=true projects nothing, so the deployment value stands") + void enabledManagementTlsLeavesTheDeploymentValueStanding() throws Exception { + writeGateway(MANAGEMENT_TLS_ENABLED); + + assertEquals(DEPLOYMENT_SUPPLIED, resolveAgainstDeploymentValue(), + "HTTPS is the secure default and needs no key to express it; projecting one here " + + "would outrank QUARKUS_MANAGEMENT_TLS_CONFIGURATION_NAME and turn the two " + + "doors onto this lever into two competing mechanisms"); + } + + @Test + @DisplayName("An absent management block projects nothing, so the deployment value stands") + void absentManagementBlockLeavesTheDeploymentValueStanding() throws Exception { + writeGateway(NO_MANAGEMENT_BLOCK); + + assertEquals(DEPLOYMENT_SUPPLIED, resolveAgainstDeploymentValue(), + "a document that says nothing about management TLS must leave the deployment's own " + + "route onto the same Quarkus key working unchanged"); + } + + @Test + @DisplayName("The projected key set is exactly one key") + void projectedKeySetIsExactlyOneKey() throws Exception { + writeGateway(MANAGEMENT_TLS_DISABLED); + + NeutralTlsConfigSource source = new NeutralTlsConfigSource(configDir); + + assertAll("projection", + () -> assertEquals(Set.of(MANAGEMENT_TLS_CONFIGURATION_NAME), source.getPropertyNames(), + "the cardinality is pinned deliberately: this source outranks every environment " + + "variable, so a second projected key must be a visible diff rather " + + "than an unnoticed widening of the policy-keys-only constraint"), + () -> assertEquals(Map.of(MANAGEMENT_TLS_CONFIGURATION_NAME, PLAIN_MANAGEMENT), + source.getProperties(), "getProperties must agree with getPropertyNames")); + } + + @ParameterizedTest + @DisplayName("A shape the schema will reject projects nothing rather than guessing") + @ValueSource(strings = { + "version: 1\nmanagement: not-a-map\n", + "version: 1\nmanagement:\n tls: not-a-map\n", + "version: 1\nmanagement:\n tls:\n enabled: \"false\"\n", + "version: 1\nmanagement:\n tls:\n enabled: 0\n" + }) + void malformedManagementShapeLeavesTheDeploymentValueStanding(String document) throws Exception { + writeGateway(document); + + assertEquals(DEPLOYMENT_SUPPLIED, resolveAgainstDeploymentValue(), + "this parse runs before the schema validates the document, so an unexpected shape must " + + "read as 'not explicitly disabled' — keeping TLS on is the fail-safe direction, " + + "and ConfigLoader reports the real error moments later"); + } + + @Test + @DisplayName("An absent document degrades silently rather than failing the boot early") + void absentDocumentProjectsNoKey() { + NeutralTlsConfigSource source = new NeutralTlsConfigSource(configDir); + + assertTrue(source.getProperties().isEmpty(), + "ConfigLoader reports a missing gateway.yaml with full context moments later; failing " + + "here would replace that diagnostic with a context-free config-system error"); + } + + @Test + @DisplayName("A malformed document degrades silently rather than failing the boot early") + void malformedDocumentProjectsNoKey() throws Exception { + writeGateway("this: [is: not: valid: yaml"); + + NeutralTlsConfigSource source = new NeutralTlsConfigSource(configDir); + + assertTrue(source.getProperties().isEmpty(), "a parse failure here is re-reported by ConfigLoader"); + } + + @ParameterizedTest + @DisplayName("No deployment-bound or key-material key is ever projected") + @ValueSource(strings = { + "quarkus.management.port", + "quarkus.http.port", + "quarkus.http.ssl-port", + "quarkus.tls.key-store.pem.0.cert", + "quarkus.tls.key-store.p12.path", + "quarkus.tls.trust-store.p12.path", + "quarkus.tls." + PLAIN_MANAGEMENT + ".key-store.p12.path" + }) + void neverProjectsADeploymentBoundOrKeyMaterialKey(String forbiddenKey) throws Exception { + // the actively-projecting document, so the constraint is asserted while the source is live + writeGateway(MANAGEMENT_TLS_DISABLED); + + NeutralTlsConfigSource source = new NeutralTlsConfigSource(configDir); + + assertNull(source.getValue(forbiddenKey), + () -> forbiddenKey + " must never be projected: this source outranks every environment " + + "variable, so projecting a port would override the deployment's own knob and " + + "projecting key material would populate the default TLS registry bucket"); + } + + @Test + @DisplayName("The source names itself after the document it reads") + void nameIdentifiesTheDocumentItReads() { + assertEquals("NeutralTlsConfigSource[gateway.yaml]", new NeutralTlsConfigSource(configDir).getName()); + } + + @Test + @DisplayName("getOrdinal reports the declared ordinal") + void getOrdinalReportsTheDeclaredOrdinal() { + assertEquals(NeutralTlsConfigSource.ORDINAL, new NeutralTlsConfigSource(configDir).getOrdinal()); + } + + @Test + @DisplayName("The registered provider yields exactly one source") + void providerYieldsExactlyOneSource() { + var sources = new NeutralTlsConfigSource.Provider().getConfigSources(getClass().getClassLoader()); + + assertEquals(1, StreamSupport.stream(sources.spliterator(), false).count(), + "the ServiceLoader entry must contribute the single neutral source, no more"); + } + + /** + * Stands in for the deployment's environment-variable source at its real ordinal, so precedence is + * genuinely contested rather than resolved by default against an empty configuration. + */ + private record StandInEnvironmentSource(Map properties) implements ConfigSource { + + @Override + public int getOrdinal() { + return ENVIRONMENT_ORDINAL; + } + + @Override + public String getName() { + return "StandInEnvironmentSource"; + } + + @Override + public Set getPropertyNames() { + return properties.keySet(); + } + + @Override + public @Nullable String getValue(String propertyName) { + return properties.get(propertyName); + } + + @Override + public Map getProperties() { + return properties; + } + } +} diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/config/SingleSourceTlsContractTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/config/SingleSourceTlsContractTest.java new file mode 100644 index 00000000..a715e590 --- /dev/null +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/config/SingleSourceTlsContractTest.java @@ -0,0 +1,245 @@ +/* + * Copyright © 2026 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.gateway.config; + +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.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Set; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Machine-asserts the completion bar of the neutral TLS surface: no raw server-TLS + * {@code quarkus.*} POLICY key remains in the shipped {@code application.properties}' default + * profile, and the one build-time knob that left the runtime surface really landed in the + * build rather than simply being deleted. + *

+ * Why this test exists at all. The keys this plan removed were not merely + * redundant — they were inert. Quarkus consults its TLS-registry configuration only when the + * server certificate also comes from that registry, and this gateway supplies its certificate through + * {@code quarkus.http.ssl.certificate.*}, so a declared protocol floor or cipher allowlist never + * reached the listener. A configuration surface that reads as enforcement while enforcing nothing is + * the specific defect the neutral surface replaces, and nothing stops it growing back by accident: a + * future contributor reaching for a familiar {@code quarkus.tls.*} name would reintroduce it silently + * and green. This test is the guard that makes that reintroduction a failing build. + *

+ * The allowlist is deliberately explicit and deliberately small. Every entry is a + * ruling-sanctioned exception with its reason recorded inline and in + * {@code doc/configuration.adoc}'s per-key verdict table. Widening it is therefore a visible diff + * that a reviewer must weigh against the documented classification, rather than a silent relaxation + * of the bar. + * + * @author API Sheriff Team + * @since 1.0 + */ +@DisplayName("Single-source TLS contract") +class SingleSourceTlsContractTest { + + /** The module base directory — surefire runs with the module root as the working directory. */ + private static final Path MODULE = Path.of(System.getProperty("user.dir")); + private static final Path APPLICATION_PROPERTIES = MODULE.resolve("src/main/resources/application.properties"); + private static final Path POM = MODULE.resolve("pom.xml"); + + private static final String NATIVE_SSL_KEY = "quarkus.ssl.native"; + + /** + * The configuration namespaces that carry server-TLS settings. A default-profile key under one of + * these is presumed to be TLS POLICY unless it is material (below) or explicitly excepted. + *

+ * {@code quarkus.http.ssl} is listed without a trailing dot on purpose, so the bar also + * reaches the sibling {@code quarkus.http.ssl-port}. Requiring the dot would silently exclude it, + * and its allowlist entry below would then be decorative rather than load-bearing — the very + * shape of defect this test exists to catch. + */ + private static final List SERVER_TLS_NAMESPACES = List.of( + "quarkus.tls.", + "quarkus.ssl.", + "quarkus.http.ssl", + "quarkus.management.ssl"); + + /** + * Server-TLS-relevant keys that no namespace prefix reaches. {@code insecure-requests} governs how + * the TLS listener treats plain-HTTP traffic, so it is in scope of the bar even though its name + * sits outside every {@code ssl}/{@code tls} namespace. + */ + private static final Set ADDITIONAL_IN_SCOPE = Set.of("quarkus.http.insecure-requests"); + + /** + * Substrings identifying certificate / key / trust MATERIAL rather than policy. Material is + * deployment-bound by the same ruling that makes policy neutral, so it legitimately stays outside + * {@code gateway.yaml} and is not what this bar is about. + */ + private static final List MATERIAL_MARKERS = List.of( + "certificate", "key-store", "trust-store", "keystore", "truststore", "credentials-provider"); + + /** + * The ruling-sanctioned exceptions, each named individually so widening this set is a visible + * diff. Every entry is also recorded in {@code doc/configuration.adoc}'s per-key verdict table and + * in ADR-0025 — an undocumented exception would be a silent trim of the completion bar. + */ + private static final Set SANCTIONED_EXCEPTIONS = Set.of( + // Deployment-bound port. Promoting it would break the ADR-0017 SNI split, where a + // deployment moves the terminated listener to the internal loopback port through + // QUARKUS_HTTP_SSL_PORT — a topology a gateway.yaml-owned port could not express. + "quarkus.http.ssl-port", + // Deployment-bound exposure knob of the same class: a port/exposure decision the + // deployment owns, not TLS policy. + "quarkus.http.insecure-requests", + // NOT policy: the key-less TLS bucket the plain-HTTP management opt-out selects. Its + // emptiness IS the mechanism — reload-period declares the map key without declaring any + // material. Adding key material to this bucket would defeat the opt-out; adding a + // DEFAULT quarkus.tls.key-store.* bucket would make management inherit HTTPS invisibly. + "quarkus.tls.plain-management.reload-period"); + + @Test + @DisplayName("No raw server-TLS policy key remains in the default profile") + void noRawServerTlsPolicyKeyRemainsInTheDefaultProfile() throws Exception { + List offenders = defaultProfileKeys().stream() + .filter(SingleSourceTlsContractTest::isInScopeOfTheBar) + .filter(key -> !isMaterial(key)) + .filter(key -> !SANCTIONED_EXCEPTIONS.contains(key)) + .toList(); + + assertTrue(offenders.isEmpty(), + () -> "raw server-TLS policy key(s) found in application.properties' default profile: " + + offenders + ". TLS policy is single-sourced from gateway.yaml's `tls` and " + + "`management` blocks and bound by TlsServerCustomizer / NeutralTlsConfigSource " + + "(ADR-0025). A raw key here either competes with that binding or — as the keys " + + "this bar replaced did — reaches no listener at all while reading as " + + "enforcement. If the key is genuinely deployment-bound or build-time, add it " + + "to SANCTIONED_EXCEPTIONS *and* to doc/configuration.adoc's per-key verdict " + + "table; an undocumented exception is a silent trim."); + } + + @Test + @DisplayName("Every sanctioned exception is real: present in the file and reached by the detector") + void everySanctionedExceptionIsLoadBearing() throws Exception { + List declaredKeys = allKeys(); + + for (String exception : SANCTIONED_EXCEPTIONS) { + assertTrue(declaredKeys.contains(exception), + () -> "SANCTIONED_EXCEPTIONS names '" + exception + "', which application.properties " + + "no longer declares. A stale exception is an inert allowlist entry that " + + "reads as a live carve-out — remove it, and remove its row from " + + "doc/configuration.adoc's per-key verdict table."); + assertTrue(isInScopeOfTheBar(exception) && !isMaterial(exception), + () -> "SANCTIONED_EXCEPTIONS names '" + exception + "', but the detector would not " + + "have flagged it anyway — the entry is decorative, so the bar is weaker " + + "than it reads. Either widen the detector or drop the entry."); + } + } + + @Test + @DisplayName("Profiled keys are outside the bar, but the exceptions are not smuggled through one") + void theBarAppliesToTheDefaultProfile() throws Exception { + // The %it JWKS trust-material carve-out is deliberately outside the bar: it binds a logical + // trust-profile name to concrete test anchors (ADR-0011) and is material, not policy. This + // assertion pins the reason rather than the carve-out's contents, so the bar cannot be + // sidestepped by moving a policy key behind a profile prefix. + List profiledPolicyKeys = profiledKeys().stream() + .filter(SingleSourceTlsContractTest::isInScopeOfTheBar) + .filter(key -> !isMaterial(key)) + .filter(key -> !SANCTIONED_EXCEPTIONS.contains(key)) + .toList(); + + assertTrue(profiledPolicyKeys.isEmpty(), + () -> "profile-scoped server-TLS policy key(s) found: " + profiledPolicyKeys + + ". A profile prefix does not change a key's class — policy belongs in " + + "gateway.yaml whichever profile declares it."); + } + + @Test + @DisplayName("The native SSL knob is absent from application.properties") + void nativeSslKnobIsAbsentFromTheRuntimeSurface() throws Exception { + assertFalse(allKeys().contains(NATIVE_SSL_KEY), + NATIVE_SSL_KEY + " is a BUILD-TIME GraalVM knob fixed when the image is built. In " + + "application.properties it would only look like a runtime key, since no " + + "runtime document could make it take effect (ADR-0025)"); + } + + @Test + @DisplayName("The native SSL knob is present in the pom's native profile") + void nativeSslKnobIsPresentInTheNativeProfile() throws Exception { + String pom = Files.readString(POM); + + int nativeProfileId = pom.indexOf("native"); + assertTrue(nativeProfileId >= 0, "the pom must declare a with native"); + int profileEnd = pom.indexOf("", nativeProfileId); + assertTrue(profileEnd > nativeProfileId, "the native profile must be closed"); + int knob = pom.indexOf("<" + NATIVE_SSL_KEY + ">", nativeProfileId); + + // This is the half that stops the relocation degenerating into a silent deletion. Asserting + // only the absence above would pass just as happily if the knob had been dropped outright, + // and a native image without SSL support fails at runtime rather than at build. + assertTrue(knob > nativeProfileId && knob < profileEnd, + () -> NATIVE_SSL_KEY + " must be declared INSIDE the native profile — it was relocated " + + "there, not deleted, and the profile is active exactly when the knob has " + + "meaning. Found at index " + knob + ", native profile spans [" + + nativeProfileId + ", " + profileEnd + ")"); + assertEquals("true", valueOfPomProperty(pom, knob), + NATIVE_SSL_KEY + " must be enabled — a relocated-but-disabled knob is the same " + + "regression as a deleted one"); + } + + private static String valueOfPomProperty(String pom, int elementStart) { + int valueStart = pom.indexOf('>', elementStart) + 1; + int valueEnd = pom.indexOf('<', valueStart); + return pom.substring(valueStart, valueEnd).strip(); + } + + /** Keys declared without a {@code %profile} prefix — the shipped default posture. */ + private static List defaultProfileKeys() throws IOException { + return propertyLines().stream().filter(line -> !line.startsWith("%")).map(SingleSourceTlsContractTest::keyOf) + .toList(); + } + + /** Keys declared under a {@code %profile} prefix, with the prefix stripped. */ + private static List profiledKeys() throws IOException { + return propertyLines().stream().filter(line -> line.startsWith("%")) + .map(line -> keyOf(line.substring(line.indexOf('.') + 1))).toList(); + } + + private static List allKeys() throws IOException { + return propertyLines().stream() + .map(line -> line.startsWith("%") ? line.substring(line.indexOf('.') + 1) : line) + .map(SingleSourceTlsContractTest::keyOf).toList(); + } + + private static String keyOf(String line) { + int separator = line.indexOf('='); + return separator < 0 ? line.strip() : line.substring(0, separator).strip(); + } + + private static List propertyLines() throws IOException { + return Files.readAllLines(APPLICATION_PROPERTIES).stream().map(String::strip) + .filter(line -> !line.isEmpty() && !line.startsWith("#")).toList(); + } + + private static boolean isInScopeOfTheBar(String key) { + return SERVER_TLS_NAMESPACES.stream().anyMatch(key::startsWith) || ADDITIONAL_IN_SCOPE.contains(key); + } + + private static boolean isMaterial(String key) { + return MATERIAL_MARKERS.stream().anyMatch(key::contains); + } +} diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/config/load/ConfigLoaderTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/config/load/ConfigLoaderTest.java index 0594c082..abf6be64 100644 --- a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/config/load/ConfigLoaderTest.java +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/config/load/ConfigLoaderTest.java @@ -55,6 +55,17 @@ */ class ConfigLoaderTest { + /** + * The operator-facing sentence {@code gateway.schema.json} attaches to the always-failing + * {@code management.port} subschema through the {@code errorMessage} extension. Copied verbatim + * from the schema — the point of the assertion is that the schema's own sentence, not a generic + * unknown-key message, reaches the operator. + */ + private static final String MANAGEMENT_PORT_REJECTION = + "the management port is deployment-bound and is not settable in gateway.yaml: " + + "set quarkus.management.port (environment QUARKUS_MANAGEMENT_PORT, default 9000) " + + "instead — see ADR-0025"; + @TempDir Path configDir; @@ -88,6 +99,10 @@ void bindsValidGatewayConfigAndResolvesSecrets() throws Exception { assertEquals(List.of(HttpMethod.GET, HttpMethod.POST, HttpMethod.PUT, HttpMethod.DELETE), gateway.allowedMethods()); assertEquals(Optional.of("1.3"), gateway.tls().orElseThrow().minVersion()); + assertEquals(List.of("TLS_AES_256_GCM_SHA384", "TLS_CHACHA20_POLY1305_SHA256"), + gateway.tls().orElseThrow().cipherSuites(), + "the neutral tls.cipher_suites allowlist must bind from gateway.yaml"); + assertEquals(List.of("h2", "http/1.1"), gateway.tls().orElseThrow().alpn()); assertEquals(new UpstreamDefaultsConfig(true, false), gateway.upstreamDefaults().orElseThrow()); assertEquals(Optional.of("s3cr3t"), gateway.oidc().orElseThrow().clientSecret()); assertEquals(1, gateway.tokenValidation().orElseThrow().issuers().size()); @@ -365,6 +380,40 @@ void acceptsForwardedBlockDeclaringEmptyTrustedProxies() throws Exception { "an explicitly empty trusted_proxies list means no proxy is trusted and stays valid"); } + @Test + void acceptsPolicyOnlyManagementBlock() throws Exception { + writeConfig("gateway.yaml", """ + version: 1 + management: + tls: + enabled: true + """); + + ConfigLoader.LoadedConfig loaded = loader(Map.of()).load(); + + assertTrue(loaded.gateway().management().orElseThrow().tls().orElseThrow().enabled(), + "a policy-only management block must bind without a port component"); + } + + @Test + void rejectsManagementPortNamingTheDeploymentKnobAtItsOwnPointer() throws Exception { + writeConfig("gateway.yaml", """ + version: 1 + management: + port: 9100 + """); + + ConfigLoader loader = loader(Map.of()); + ConfigLoadException exception = assertThrows(ConfigLoadException.class, loader::load); + + assertTrue(exception.errors().stream() + .anyMatch(error -> "gateway.yaml".equals(error.file()) + && error.pointer().contains("/management/port") + && error.message().contains(MANAGEMENT_PORT_REJECTION)), + () -> "expected the management.port rejection to carry the deployment-knob sentence at " + + "its own JSON Pointer, got: " + exception.errors()); + } + @Test void bindsAnchorsBlockAndInjectsTheMapKeyAsName() throws Exception { writeConfig("gateway.yaml", """ diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/config/model/ConfigModelContractTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/config/model/ConfigModelContractTest.java index 370aa4c0..6a301630 100644 --- a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/config/model/ConfigModelContractTest.java +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/config/model/ConfigModelContractTest.java @@ -86,6 +86,7 @@ private static GatewayConfig gatewayConfig() { .version(1) .metadata(Optional.of(new Metadata(Optional.of("2024-01")))) .tls(Optional.of(tlsConfig())) + .management(Optional.of(managementConfig())) .securityHeaders(Optional.of(securityHeadersConfig())) .securityDefaults(Optional.of(new SecurityDefaultsConfig(Optional.of("strict")))) .allowedMethods(List.of(HttpMethod.GET, HttpMethod.POST)) @@ -97,9 +98,16 @@ private static GatewayConfig gatewayConfig() { .build(); } + private static ManagementConfig managementConfig() { + return ManagementConfig.builder() + .tls(Optional.of(new ManagementConfig.ManagementTls(true))) + .build(); + } + private static TlsConfig tlsConfig() { return TlsConfig.builder() .minVersion(Optional.of("TLSv1.3")) + .cipherSuites(List.of("TLS_AES_256_GCM_SHA384", "TLS_CHACHA20_POLY1305_SHA256")) .alpn(List.of("h2", "http/1.1")) .passthroughSni(Map.of("internal.example.com", "internal-alias")) .mtls(Optional.of(new TlsConfig.Mtls(true, Optional.of("/etc/ca.pem")))) @@ -269,6 +277,10 @@ static Stream valueObjects() { voCase("Metadata", new Metadata(Optional.of("v1")), new Metadata(Optional.of("v1")), new Metadata(Optional.of("v2"))), voCase("TlsConfig", tlsConfig(), tlsConfig(), TlsConfig.builder().build()), + voCase("ManagementConfig", managementConfig(), managementConfig(), + ManagementConfig.builder().build()), + voCase("ManagementConfig.ManagementTls", new ManagementConfig.ManagementTls(true), + new ManagementConfig.ManagementTls(true), new ManagementConfig.ManagementTls(false)), voCase("TlsConfig.Mtls", new TlsConfig.Mtls(true, Optional.of("/ca")), new TlsConfig.Mtls(true, Optional.of("/ca")), new TlsConfig.Mtls(false, Optional.empty())), voCase("SecurityHeadersConfig", securityHeadersConfig(), securityHeadersConfig(), @@ -419,8 +431,8 @@ void anchorConfigBuilderMatchesConstructor() { @Test void gatewayConfigBuilderMatchesConstructor() { GatewayConfig viaCtor = new GatewayConfig(2, Optional.empty(), Optional.empty(), Optional.empty(), - Optional.empty(), List.of(HttpMethod.GET), Map.of("api", anchorConfig()), Optional.empty(), - Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty()); + Optional.empty(), Optional.empty(), List.of(HttpMethod.GET), Map.of("api", anchorConfig()), + Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty()); GatewayConfig viaBuilder = GatewayConfig.builder().version(2).allowedMethods(List.of(HttpMethod.GET)) .anchors(Map.of("api", anchorConfig())).build(); assertEquals(viaCtor, viaBuilder); @@ -445,9 +457,11 @@ class NullNormalization { @Test void gatewayConfigNormalizesAllAbsentComponents() { - GatewayConfig cfg = new GatewayConfig(1, null, null, null, null, null, null, null, null, null, null, null); + GatewayConfig cfg = new GatewayConfig(1, null, null, null, null, null, null, null, null, null, null, null, + null); assertTrue(cfg.metadata().isEmpty()); assertTrue(cfg.tls().isEmpty()); + assertTrue(cfg.management().isEmpty()); assertTrue(cfg.securityHeaders().isEmpty()); assertTrue(cfg.securityDefaults().isEmpty()); assertTrue(cfg.allowedMethods().isEmpty()); @@ -548,8 +562,9 @@ void collectionBearingRecordsNormalizeNullToEmpty() { assertTrue(new TokenValidationConfig(null).issuers().isEmpty()); assertTrue(new ForwardedConfig(null, null, null).trustedProxies().isEmpty()); assertTrue(new ForwardConfig(null, null, null).setHeaders().isEmpty()); - assertTrue(new TlsConfig(null, null, null, null).alpn().isEmpty()); - assertTrue(new TlsConfig(null, null, null, null).passthroughSni().isEmpty()); + assertTrue(new TlsConfig(null, null, null, null, null).cipherSuites().isEmpty()); + assertTrue(new TlsConfig(null, null, null, null, null).alpn().isEmpty()); + assertTrue(new TlsConfig(null, null, null, null, null).passthroughSni().isEmpty()); assertTrue(new MatchConfig("/p", null, null, null).methods().isEmpty()); assertTrue(new SecurityFilterConfig(null, null, null, null, null, null, null, null, null, null) .allowedPaths().isEmpty()); diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/quarkus/SheriffMetricsTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/quarkus/SheriffMetricsTest.java index 483c22e2..dfda3f50 100644 --- a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/quarkus/SheriffMetricsTest.java +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/quarkus/SheriffMetricsTest.java @@ -306,7 +306,8 @@ private Optional serverMode() { private GatewayConfig configWith(Optional metadata, Optional tokenValidation, Optional oidc) { return new GatewayConfig(1, metadata, Optional.empty(), Optional.empty(), Optional.empty(), - null, null, Optional.empty(), Optional.empty(), tokenValidation, oidc, Optional.empty()); + Optional.empty(), null, null, Optional.empty(), Optional.empty(), tokenValidation, oidc, + Optional.empty()); } } diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/tls/ManagementPlainHttpAuditTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/tls/ManagementPlainHttpAuditTest.java new file mode 100644 index 00000000..c616c8d5 --- /dev/null +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/tls/ManagementPlainHttpAuditTest.java @@ -0,0 +1,110 @@ +/* + * Copyright © 2026 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.gateway.tls; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Optional; + + +import de.cuioss.test.juli.LogAsserts; +import de.cuioss.test.juli.TestLogLevel; +import de.cuioss.test.juli.junit5.EnableTestLogger; + +import io.quarkus.runtime.StartupEvent; +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.tls.BaseTlsConfiguration; +import io.quarkus.tls.TlsConfiguration; +import io.vertx.core.net.KeyCertOptions; +import io.vertx.core.net.PemKeyCertOptions; +import jakarta.enterprise.event.Event; +import jakarta.inject.Inject; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Tests for {@link ManagementPlainHttpAudit}. + *

+ * The load-bearing test here is {@link #warnsThroughTheRealStartupEventPath()}: it fires a + * {@link StartupEvent} through the container's own event bus and asserts the warning appears, rather + * than calling the observer method directly. Lesson 2026-07-20-18-002 is precisely that a + * normal-scoped observer bean can get a lazy CDI proxy that is never touched — the check silently + * never runs in production while a test that invokes the method directly stays green. Only delivery + * through the real event bus proves the observer is registered and reachable. + *

+ * The test boot resolves to plain management: the test configuration supplies its certificate through + * {@code quarkus.http.ssl.certificate.*}, so the default TLS registry bucket carries no key material, + * and no management certificate is configured. That is the same shape the opt-out produces, which is + * why the positive case needs no override. + */ +@QuarkusTest +@EnableTestLogger +@DisplayName("Management plain-HTTP audit") +class ManagementPlainHttpAuditTest { + + @Inject + Event startupEvent; + + @Test + @DisplayName("Warns through the real startup-event path, not by direct invocation") + void warnsThroughTheRealStartupEventPath() { + startupEvent.fire(new StartupEvent()); + + LogAsserts.assertLogMessagePresentContaining(TestLogLevel.WARN, + "Management interface is serving PLAIN HTTP on port"); + } + + @Test + @DisplayName("A resolved configuration carrying no key material means plain HTTP") + void keyLessResolvedConfigurationMeansPlainHttp() { + assertTrue(ManagementPlainHttpAudit.resolvesToPlainHttp(Optional.of(keyLessConfiguration()), true), + "selecting a key-less bucket replaces the deployment's certificate rather than adding " + + "to it, so the listener ends up with no key material even when a certificate " + + "is configured"); + } + + @Test + @DisplayName("A resolved configuration carrying key material means HTTPS") + void keyBearingResolvedConfigurationMeansHttps() { + assertFalse(ManagementPlainHttpAudit.resolvesToPlainHttp(Optional.of(keyBearingConfiguration()), false), + "a bucket that carries key material keeps the management listener on HTTPS"); + } + + @Test + @DisplayName("With no resolved configuration the management certificate decides") + void withoutAResolvedConfigurationTheCertificateDecides() { + assertFalse(ManagementPlainHttpAudit.resolvesToPlainHttp(Optional.empty(), true), + "quarkus.management.ssl.certificate.* alone still produces HTTPS — this is the shipped " + + "default posture and it must not warn"); + assertTrue(ManagementPlainHttpAudit.resolvesToPlainHttp(Optional.empty(), false), + "no bucket and no certificate leaves nothing to terminate with"); + } + + private static TlsConfiguration keyLessConfiguration() { + return new BaseTlsConfiguration() { + }; + } + + private static TlsConfiguration keyBearingConfiguration() { + return new BaseTlsConfiguration() { + @Override + public KeyCertOptions getKeyStoreOptions() { + return new PemKeyCertOptions(); + } + }; + } +} diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/tls/MtlsServerCustomizerTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/tls/MtlsServerCustomizerTest.java index 40695e5a..c3108ec2 100644 --- a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/tls/MtlsServerCustomizerTest.java +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/tls/MtlsServerCustomizerTest.java @@ -104,6 +104,25 @@ void enabledWithoutClientCaFailsBoot() { "enabled mTLS without a client_ca trust anchor must not start a require-client-auth listener"); } + @Test + @DisplayName("client-auth is owned solely by the mTLS customizer, not by a raw configuration default") + void clientAuthIsOwnedByTheMtlsCustomizer() { + // Arrange — a listener whose client-auth was left at the framework default, as it now is + // since quarkus.http.ssl.client-auth was deleted from application.properties: gateway.yaml's + // tls.mtls block is the single source, and it is this customizer that puts it in force. + HttpServerOptions options = new HttpServerOptions(); + assertEquals(ClientAuth.NONE, options.getClientAuth(), + "with no raw client-auth key declared, the listener starts with client-auth off"); + + // Act + customizerFor(mtls(true, Optional.of(CLIENT_CA_PATH))).customizeHttpsServer(options); + + // Assert + assertEquals(ClientAuth.REQUIRED, options.getClientAuth(), + "the customizer alone raises client-auth, so no raw quarkus.http.ssl.client-auth default is needed"); + assertNotNull(options.getTrustOptions(), "the trust anchor comes from gateway.yaml's client_ca"); + } + private static MtlsServerCustomizer customizerFor(TlsConfig.Mtls mtls) { GatewayConfig gateway = GatewayConfig.builder().version(1) .tls(Optional.of(TlsConfig.builder().mtls(Optional.of(mtls)).build())) diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/tls/TlsServerCustomizerTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/tls/TlsServerCustomizerTest.java new file mode 100644 index 00000000..a8b0ac83 --- /dev/null +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/tls/TlsServerCustomizerTest.java @@ -0,0 +1,277 @@ +/* + * Copyright © 2026 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.gateway.tls; + +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.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Optional; +import java.util.Set; + + +import de.cuioss.sheriff.gateway.config.model.GatewayConfig; +import de.cuioss.sheriff.gateway.config.model.TlsConfig; + +import io.vertx.core.http.HttpServerOptions; +import io.vertx.core.http.HttpVersion; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link TlsServerCustomizer}'s {@code gateway.yaml} → listener-option mapping: the + * declared TLS floor expands to an enabled protocol set, the declared cipher suites become the + * listener allowlist, and the declared ALPN protocols switch ALPN on. Each assertion runs against a + * real {@link HttpServerOptions} instance — the same object Quarkus hands the customizer — so the + * test proves the neutral key actually mutates the listener rather than that a flag was read. + *

+ * Absent keys must remain no-ops, because an omitted {@code tls} block has to preserve today's + * Quarkus defaults exactly. + */ +@DisplayName("TlsServerCustomizer") +class TlsServerCustomizerTest { + + private static final String TLS_V1_2 = "TLSv1.2"; + private static final String TLS_V1_3 = "TLSv1.3"; + private static final String SUITE_AES_256 = "TLS_AES_256_GCM_SHA384"; + private static final String SUITE_CHACHA20 = "TLS_CHACHA20_POLY1305_SHA256"; + + /** + * The verbatim {@code tls.cipher_suites} allowlist of + * {@code integration-tests/src/main/docker/sheriff-config/gateway.yaml}: two TLS 1.3 suites plus + * the ECDHE_RSA pair that covers TLS 1.2 against the suite's RSA server certificate. Validation + * that rejected any of these would break the containerised stack, so they are asserted here. + */ + private static final List IT_FIXTURE_SUITES = List.of( + "TLS_AES_256_GCM_SHA384", + "TLS_AES_128_GCM_SHA256", + "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384", + "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256"); + + @Nested + @DisplayName("min_version") + class MinVersion { + + @Test + @DisplayName("a 1.2 floor enables TLSv1.2 and TLSv1.3") + void floor12EnablesBothModernProtocols() { + HttpServerOptions options = new HttpServerOptions(); + + customizerFor(TlsConfig.builder().minVersion(Optional.of("1.2")).build()) + .customizeHttpsServer(options); + + assertEquals(Set.of(TLS_V1_2, TLS_V1_3), options.getEnabledSecureTransportProtocols(), + "a 1.2 floor must admit TLSv1.2 and TLSv1.3"); + } + + @Test + @DisplayName("a 1.3 floor enables TLSv1.3 only") + void floor13EnablesOnlyTls13() { + HttpServerOptions options = new HttpServerOptions(); + + customizerFor(TlsConfig.builder().minVersion(Optional.of("1.3")).build()) + .customizeHttpsServer(options); + + assertEquals(Set.of(TLS_V1_3), options.getEnabledSecureTransportProtocols(), + "a 1.3 floor must exclude TLSv1.2"); + } + + @Test + @DisplayName("an absent min_version leaves the platform default protocol set untouched") + void absentMinVersionIsNoOp() { + HttpServerOptions untouched = new HttpServerOptions(); + HttpServerOptions options = new HttpServerOptions(); + + customizerFor(TlsConfig.builder().build()).customizeHttpsServer(options); + + assertEquals(untouched.getEnabledSecureTransportProtocols(), + options.getEnabledSecureTransportProtocols(), + "an omitted floor must preserve the default protocol set"); + } + + @Test + @DisplayName("an unsupported min_version fails the boot (fail-closed)") + void unsupportedMinVersionFailsBoot() { + TlsServerCustomizer customizer = customizerFor( + TlsConfig.builder().minVersion(Optional.of("1.1")).build()); + HttpServerOptions options = new HttpServerOptions(); + + assertThrows(IllegalStateException.class, () -> customizer.customizeHttpsServer(options), + "an unrecognized TLS floor must not silently start a listener with default protocols"); + } + } + + @Nested + @DisplayName("cipher_suites") + class CipherSuites { + + @Test + @DisplayName("declared suites become the listener allowlist") + void declaredSuitesBecomeAllowlist() { + HttpServerOptions options = new HttpServerOptions(); + + customizerFor(TlsConfig.builder().cipherSuites(List.of(SUITE_AES_256, SUITE_CHACHA20)).build()) + .customizeHttpsServer(options); + + assertEquals(Set.of(SUITE_AES_256, SUITE_CHACHA20), options.getEnabledCipherSuites(), + "each declared cipher suite must be enabled on the listener"); + } + + @Test + @DisplayName("an absent cipher_suites list leaves the default suite selection untouched") + void absentCipherSuitesIsNoOp() { + HttpServerOptions options = new HttpServerOptions(); + + customizerFor(TlsConfig.builder().build()).customizeHttpsServer(options); + + assertTrue(options.getEnabledCipherSuites().isEmpty(), + "an omitted allowlist must not restrict the platform default suite selection"); + } + + @Test + @DisplayName("an unsupported cipher suite fails the boot (fail-closed)") + void unsupportedCipherSuiteFailsBoot() { + // Arrange — nothing downstream rejects this name: Vert.x binds the listener and the + // defect surfaces only as an SSLHandshakeException on every client connection. + TlsServerCustomizer customizer = customizerFor( + TlsConfig.builder().cipherSuites(List.of("TLS_TOTALLY_BOGUS_SUITE")).build()); + HttpServerOptions options = new HttpServerOptions(); + + // Act + IllegalStateException failure = assertThrows(IllegalStateException.class, + () -> customizer.customizeHttpsServer(options), + "an unsupported suite must not bind a listener that fails every handshake"); + + // Assert + assertTrue(failure.getMessage().contains("TLS_TOTALLY_BOGUS_SUITE"), + "the abort message must name the offending suite: " + failure.getMessage()); + } + + @Test + @DisplayName("one bad entry among supported ones still fails the boot (no silent narrowing)") + void partiallyMistypedAllowlistFailsBoot() { + // Arrange — the dangerous case: the valid entries would still negotiate, so the typo + // would silently narrow the declared policy instead of announcing itself. + TlsServerCustomizer customizer = customizerFor(TlsConfig.builder() + .cipherSuites(List.of(SUITE_AES_256, "TLS_AES_256_GCM_SHA385")).build()); + HttpServerOptions options = new HttpServerOptions(); + + // Act + IllegalStateException failure = assertThrows(IllegalStateException.class, + () -> customizer.customizeHttpsServer(options), + "a single mistyped entry must abort rather than silently narrow the allowlist"); + + // Assert — the near-miss must be diagnosable, so the closest supported suite is named. + assertTrue(failure.getMessage().contains(SUITE_AES_256), + "the abort message must offer the closest supported suite: " + failure.getMessage()); + } + + @Test + @DisplayName("the integration fixture's four-suite allowlist is accepted verbatim") + void integrationFixtureAllowlistIsAccepted() { + // Arrange — the exact list from integration-tests' gateway.yaml, proven in the + // containerised suite to negotiate a real handshake under a 1.2 floor. + HttpServerOptions options = new HttpServerOptions(); + + // Act + customizerFor(TlsConfig.builder().minVersion(Optional.of("1.2")) + .cipherSuites(IT_FIXTURE_SUITES).build()).customizeHttpsServer(options); + + // Assert + assertEquals(Set.copyOf(IT_FIXTURE_SUITES), options.getEnabledCipherSuites(), + "validation must not reject the allowlist the integration stack actually negotiates"); + } + } + + @Nested + @DisplayName("alpn") + class Alpn { + + @Test + @DisplayName("declared protocols enable ALPN and are advertised in order") + void declaredProtocolsEnableAlpn() { + HttpServerOptions options = new HttpServerOptions(); + + customizerFor(TlsConfig.builder().alpn(List.of("h2", "http/1.1")).build()) + .customizeHttpsServer(options); + + assertAll("ALPN mapping", + () -> assertTrue(options.isUseAlpn(), "declaring ALPN protocols must switch ALPN on"), + () -> assertEquals(List.of(HttpVersion.HTTP_2, HttpVersion.HTTP_1_1), + options.getAlpnVersions(), "the advertised order must follow the declaration")); + } + + @Test + @DisplayName("an absent alpn list leaves ALPN as Quarkus configured it") + void absentAlpnIsNoOp() { + HttpServerOptions options = new HttpServerOptions(); + + customizerFor(TlsConfig.builder().build()).customizeHttpsServer(options); + + assertFalse(options.isUseAlpn(), "an omitted ALPN list must not switch ALPN on"); + } + + @Test + @DisplayName("an unsupported alpn protocol fails the boot (fail-closed)") + void unsupportedAlpnProtocolFailsBoot() { + TlsServerCustomizer customizer = customizerFor( + TlsConfig.builder().alpn(List.of("h3")).build()); + HttpServerOptions options = new HttpServerOptions(); + + assertThrows(IllegalStateException.class, () -> customizer.customizeHttpsServer(options), + "an unrecognized ALPN identifier must not be silently dropped from the advertised set"); + } + } + + @Test + @DisplayName("an absent tls block leaves every listener option at its default") + void absentTlsBlockIsNoOp() { + HttpServerOptions untouched = new HttpServerOptions(); + HttpServerOptions options = new HttpServerOptions(); + GatewayConfig gateway = GatewayConfig.builder().version(1).build(); + + new TlsServerCustomizer(gateway).customizeHttpsServer(options); + + assertAll("untouched defaults", + () -> assertEquals(untouched.getEnabledSecureTransportProtocols(), + options.getEnabledSecureTransportProtocols()), + () -> assertEquals(untouched.getEnabledCipherSuites(), options.getEnabledCipherSuites()), + () -> assertEquals(untouched.isUseAlpn(), options.isUseAlpn())); + } + + @Test + @DisplayName("client-auth stays untouched — it is owned by MtlsServerCustomizer") + void clientAuthIsNotTouched() { + HttpServerOptions untouched = new HttpServerOptions(); + HttpServerOptions options = new HttpServerOptions(); + + customizerFor(TlsConfig.builder().minVersion(Optional.of("1.3")) + .cipherSuites(List.of(SUITE_AES_256)).alpn(List.of("h2")).build()) + .customizeHttpsServer(options); + + assertEquals(untouched.getClientAuth(), options.getClientAuth(), + "the TLS-policy customizer must not compete with MtlsServerCustomizer over client-auth"); + } + + private static TlsServerCustomizer customizerFor(TlsConfig tls) { + GatewayConfig gateway = GatewayConfig.builder().version(1).tls(Optional.of(tls)).build(); + return new TlsServerCustomizer(gateway); + } +} diff --git a/api-sheriff/src/test/resources/application.properties b/api-sheriff/src/test/resources/application.properties index 065eb814..65edd961 100644 --- a/api-sheriff/src/test/resources/application.properties +++ b/api-sheriff/src/test/resources/application.properties @@ -1,6 +1,21 @@ -# Test profile — use existing integration-test certificates for TLS -quarkus.tls.key-store.pem.0.cert=../integration-tests/src/main/docker/certificates/localhost.crt -quarkus.tls.key-store.pem.0.key=../integration-tests/src/main/docker/certificates/localhost.key +# Test profile — use existing integration-test certificates for TLS. +# +# The certificate is supplied through quarkus.http.ssl.certificate.* — the SAME key family the +# deployment uses (QUARKUS_HTTP_SSL_CERTIFICATE_FILES / _KEY_FILES in integration-tests/docker-compose.yml +# and README.adoc). This is deliberate and load-bearing, not incidental: the two ways of supplying a +# server certificate select two DIFFERENT listener-construction paths in Quarkus. +# - quarkus.tls.key-store.* -> the default TLS-registry configuration carries KeyStoreOptions, so +# the listener is built from the TLS registry. +# - quarkus.http.ssl.certificate.* -> the registry guard fails and the listener is built from +# quarkus.http.ssl.*, which is what this gateway actually ships. +# The test profile previously used the key-store form and therefore booted a listener that production +# never builds. That divergence is exactly what let the raw quarkus.tls.protocols / .cipher-suites +# policy keys sit inert in production while looking configured (ADR-0025). Terminated-listener TLS +# policy is now single-sourced from gateway.yaml and applied by tls/TlsServerCustomizer, an +# HttpServerOptionsCustomizer that runs against the quarkus.http.ssl.* listener — so the test boot has +# to construct that listener for the customizer to be exercised at all. +quarkus.http.ssl.certificate.files=../integration-tests/src/main/docker/certificates/localhost.crt +quarkus.http.ssl.certificate.key-files=../integration-tests/src/main/docker/certificates/localhost.key quarkus.management.enabled=false quarkus.log.file.enable=false diff --git a/api-sheriff/src/test/resources/config/testboot/gateway.yaml b/api-sheriff/src/test/resources/config/testboot/gateway.yaml index 1396c92c..88f57aec 100644 --- a/api-sheriff/src/test/resources/config/testboot/gateway.yaml +++ b/api-sheriff/src/test/resources/config/testboot/gateway.yaml @@ -7,3 +7,15 @@ version: 1 metadata: config_version: "test-boot" +# TokenValidatorProducer forces the shared @GatewayValidator TokenValidator eagerly at boot, and +# that validator is built from THIS block — so a container boot needs it declared or startup aborts, +# whatever the route table's auth requirements are. The offline static JWKS file needs no IdP and no +# egress; the relative path mirrors the test certificates application.properties already reaches for. +# Nothing here validates a token; the issuer only has to assemble. +token_validation: + issuers: + - name: test-boot-static + issuer: https://idp.example.com/realms/testboot + jwks: + source: file + file: ../integration-tests/src/main/docker/certificates/test-jwks.json diff --git a/api-sheriff/src/test/resources/config/valid/gateway.yaml b/api-sheriff/src/test/resources/config/valid/gateway.yaml index 0dc691b7..1bee2ad7 100644 --- a/api-sheriff/src/test/resources/config/valid/gateway.yaml +++ b/api-sheriff/src/test/resources/config/valid/gateway.yaml @@ -4,6 +4,7 @@ metadata: config_version: "2024-05-01" tls: min_version: "1.3" + cipher_suites: ["TLS_AES_256_GCM_SHA384", "TLS_CHACHA20_POLY1305_SHA256"] alpn: ["h2", "http/1.1"] security_headers: content_type_nosniff: true diff --git a/doc/LogMessages.adoc b/doc/LogMessages.adoc index 480d2cb9..44a0d551 100644 --- a/doc/LogMessages.adoc +++ b/doc/LogMessages.adoc @@ -36,8 +36,7 @@ diagnostics use the logger directly and are not catalogued. |ApiSheriff-1 |EDGE |Route table compiled: %s route runtime(s) assembled |Logged once at startup, after the frozen route table is compiled into the immutable per-route `RouteRuntime` set the request pipeline serves from |ApiSheriff-2 |CONFIG |Configuration loaded successfully (config_version='%s') |Logged once at startup after the file-based configuration is read, validated, and assembled into the route table |ApiSheriff-3 |CONFIG |Route '%s' effective posture: anchor='%s', auth.require='%s', filter='%s' |Logged once per route during route-table assembly, reporting the materialized effective posture (resolving anchor, effective auth requirement, effective security-filter profile) — anchors vanish at runtime, so the boot log is the discoverability record (ADR-0007) -|ApiSheriff-4 |EDGE |WebSocket relay established on route '%s' (upstream '%s') |Logged once when a WebSocket upgrade completes (101 Switching Protocols) and the opaque bidirectional relay to the upstream begins (Plan 05 WebSocket processor) -|ApiSheriff-5 |EDGE |WebSocket relay on route '%s' reclaimed after idle timeout (%s seconds) |Logged when an established WebSocket relay is closed because no frame travelled in either direction for the per-route websocket.idle_timeout_seconds window; ping/pong counts as activity, so only genuinely dead sockets are reaped +|ApiSheriff-4 |EDGE |WebSocket relay established for route '%s' |Logged once when a WebSocket upgrade completes (101 Switching Protocols) and the opaque bidirectional relay to the upstream begins |ApiSheriff-6 |EDGE |Passthrough relay established for SNI '%s' to upstream '%s' |Logged once per accepted connection whose SNI matched tls.passthrough_sni, when the opaque L4 relay to the resolved backend is established; the gateway never handshakes, so the backend certificate reaches the client end-to-end |ApiSheriff-7 |EDGE |Accept-time SNI front listener started on port %s (%s passthrough SNI mapping(s)) |Logged once at startup when the Vert.x front listener binds the public TLS port; emitted only when tls.passthrough_sni is non-empty (the front listener is not started for the default single-listener topology) |ApiSheriff-10 |BFF |Server-side session established for a require:session route |Logged once when a `mode: server` BFF session is created after a successful IdP login; carries no session id, subject, or IdP `sid` @@ -60,7 +59,8 @@ diagnostics use the logger directly and are not catalogued. |ApiSheriff-102 |CONFIG |trusted_proxies entry '%s' covers a very broad address range (prefix /%s) — review whether such broad proxy trust is intended |Logged during startup validation when a trusted_proxies CIDR entry is broad-but-not-total (shorter than /8 for IPv4, /32 for IPv6); total address-space coverage is rejected outright as a validation error |ApiSheriff-103 |EDGE |Circuit breaker opened for upstream '%s' |Logged when a route's SmallRye Fault-Tolerance circuit breaker transitions to OPEN after its configured consecutive-failure threshold, so the breaker's protective posture is always an audited event |ApiSheriff-104 |EDGE |Circuit breaker closed for upstream '%s' |Logged when a route's circuit breaker transitions back to CLOSED after recovery -|ApiSheriff-105 |EDGE |WebSocket upgrade rejected on route '%s': Origin '%s' is not in the allowed_origins allowlist |Logged when a bearer WebSocket handshake presents an Origin that does not exactly match the route's allowed_origins allowlist; the upgrade is refused before any upstream dial (fail-closed CSWSH defence, GW-09 / ADR-0015) +|ApiSheriff-105 |EDGE |WebSocket upgrade rejected on route '%s': %s origin |Logged when a bearer WebSocket handshake presents an Origin that does not exactly match the route's allowed_origins allowlist; the second %s is the bounded rejection disposition (absent / foreign) — the raw offending Origin value is never logged. The upgrade is refused before any upstream dial (fail-closed CSWSH defence, GW-09 / ADR-0015) +|ApiSheriff-106 |EDGE |WebSocket relay on route '%s' reclaimed after idle timeout of %s seconds |Logged when an established WebSocket relay is closed because no frame travelled in either direction for the per-route websocket.idle_timeout_seconds window; ping/pong counts as activity, so only genuinely dead sockets are reaped |ApiSheriff-107 |EDGE |TLS ClientHello failed closed to terminated path: %s |Logged when an accept-time TLS ClientHello is failed closed to the terminated-strict path (GW-06) because it carried no usable SNI, was malformed, or exceeded the reassembly bound; records the disposition only — never the raw ClientHello bytes |ApiSheriff-108 |EDGE |Host-vs-SNI smuggle rejected before route selection: %s |Logged when a terminated request's Host header names a reserved passthrough SNI hostname and is rejected 404 before route selection; records a fixed disposition only — never the raw Host value |ApiSheriff-109 |EDGE |Reserved-path request body exceeded the %s byte ceiling (%s) — rejected 413 |Logged when a gateway-terminated reserved POST path (the OIDC `response_mode=form_post` callback or the back-channel logout receiver) declares or streams a body beyond the edge's reserved-body byte ceiling; these paths are read pre-authentication, before the per-route body cap can apply, so the ceiling is enforced at the read itself. The first `%s` is the ceiling in bytes and the second a fixed disposition (`declared-content-length` / `streamed-body`) — the offending body is never logged @@ -69,6 +69,7 @@ diagnostics use the logger directly and are not catalogued. |ApiSheriff-112 |BFF |Back-channel logout token rejected: %s |Logged when a back-channel `logout_token` fails signature or claim validation; `%s` is the non-sensitive rejection disposition (`signature` / `claims`) — the raw logout token is never logged |ApiSheriff-113 |BFF |Sealed session cookie rejected: %s |Logged when a `mode: cookie` session cookie fails to unseal and is treated as "no session"; `%s` is the non-sensitive disposition (`malformed` / `unknown-version` / `unknown-key-id` / `authentication-tag` / `payload-format`) — the offending cookie value and all key material are never logged |ApiSheriff-114 |BFF |Sealed session cookie exceeds the size budget (%s bytes) — seal refused |Logged when a sealed session cookie would exceed the browser-safe ~4 KB budget; the seal is refused rather than emitting a value the browser would silently drop, and `%s` is the bounded length only +|ApiSheriff-115 |CONFIG |Management interface is serving PLAIN HTTP on port %s — health and metrics are unencrypted and every HTTPS consumer of that port will fail. Expose it only behind a trusted boundary; restore the HTTPS default by setting management.tls.enabled back to true in gateway.yaml, or by removing a QUARKUS_MANAGEMENT_TLS_CONFIGURATION_NAME override supplied by the deployment |Logged once at startup when the management listener resolved to plain HTTP. It reports the OBSERVED EFFECTIVE STATE — the TLS material the listener actually resolved — not a declared key, so it cannot drift from reality when the activation route changes. The management interface has exactly one port, so the downgrade takes health and metrics in their entirety. It is a WARN and never a boot refusal: plain-HTTP management behind a trusted boundary is a legitimate deployment (ADR-0025). The template names the port only — never certificate paths or bucket contents |=== == ERROR Level (200-299) diff --git a/doc/adr/0025-The_whole_server-TLS_surface_is_neutral_in_gatewayyaml_and_bound_by_exactly_two_seams.adoc b/doc/adr/0025-The_whole_server-TLS_surface_is_neutral_in_gatewayyaml_and_bound_by_exactly_two_seams.adoc new file mode 100644 index 00000000..a8aaa76a --- /dev/null +++ b/doc/adr/0025-The_whole_server-TLS_surface_is_neutral_in_gatewayyaml_and_bound_by_exactly_two_seams.adoc @@ -0,0 +1,401 @@ += ADR-0025: The whole server-TLS surface is neutral in gateway.yaml and bound by exactly two seams +:toc: left +:toclevels: 2 +:sectnums: + +// adr-metadata +// Progressive-disclosure metadata block (see manage-adr SKILL.md → "ADR Template +// Structure"). Read by `manage-adr.py scan` so a caller can assess an ADR's +// relevance without reading the full file. List fields are comma-separated. +// summary: Every server-TLS knob is classified as policy, deployment-bound, or build-time; policy is named neutrally in gateway.yaml and reaches the runtime through exactly two seams — an HttpServerOptionsCustomizer for the terminated listener and one single-ordinal, policy-keys-only ConfigSource for the management interface — while ports, certificate and trust material stay deployment-supplied and build-time knobs leave the runtime surface entirely. +// tags: configuration-surface, tls, management-interface, layering, portability, config-source-ordinal, secure-by-default, framework-seam, build-time-config +// affects: api-sheriff +// supersedes: +// end-adr-metadata + +// Authoring discipline (see manage-adr SKILL.md → "Authoring Discipline"): +// ADRs are durable architectural statements, not incident write-ups. No PR numbers, +// commit SHAs, dates, or lesson IDs anywhere in the body. CLAUDE.md's "current state +// only" rule applies — describe the architecture, not the chronology. + +== Status + +Proposed + +== Context + +link:0011-gatewayyaml_exposes_JWKS_trust_and_egress_as_neutral_names_bound_by_the_deployment.adoc[ADR-0011] +settled the layering question for one surface: the JWKS client's egress allowlist and TLS trust. +`gateway.yaml` names intent in API Sheriff's own vocabulary, the deployment supplies material, and +exactly one component maps between them. That ruling was stated about `token_validation`, and it was +correct there. It was never stated as a *general* rule, and the gateway's own server-side TLS surface +grew up on the other side of that gap. + +The server-TLS surface is larger and structurally different from the JWKS one. It spans four distinct +concerns that a flat "put it in `gateway.yaml`" or "leave it in framework configuration" answer +handles badly: + +* *Policy* -- the protocol floor, the cipher allowlist, the advertised ALPN protocols, whether client + certificates are required, whether the management interface serves over TLS. These are security + decisions the product makes, identical in every environment the same document is deployed into. +* *Key and trust material* -- server certificates, private keys, trust anchors. Environment-specific, + secret, and already handled competently by the runtime's own loading, format and rotation support. +* *Ports* -- which port the terminated listener binds, which port the management interface binds, + which port the SNI front listener owns. Environment-specific, and in this gateway load-bearing for + topology rather than for security policy. +* *Build-time knobs* -- configuration fixed when the native image is built, which no runtime document + can influence at all. + +Two structural facts make the choice point sharp rather than a matter of taste. + +*First: a neutral name that binds nothing is worse than a raw framework key.* A configuration surface +can declare a TLS protocol floor and a cipher allowlist through the runtime's own registry keys and +have them reach no listener at all: the runtime consults its TLS-registry configuration only when the +server certificate is *also* supplied through that registry, and a gateway that supplies its +certificate through the HTTP-SSL certificate keys instead never passes that guard. The declared floor +and allowlist are then inert. That is not a cosmetic defect. A declared TLS floor reads as enforcement +to every reviewer, operator and auditor who sees it, and it is precisely the kind of claim a security +gateway must not make falsely. Any surface this project offers for TLS policy therefore has to be +*bound*, and the binding has to be asserted rather than assumed. + +*Second: the two listeners do not share a seam.* The terminated main HTTPS listener is configured +through `HttpServerOptionsCustomizer` beans, which the runtime enumerates from the CDI container in +`doServerStart` *after* it has built the SSL options -- so a customizer can observe and overwrite what +the configuration produced. The management interface is initialised on a different path that never +enumerates those beans at all. A single mechanism cannot reach both listeners. Whatever surface +`gateway.yaml` exposes must therefore be bound twice, by two different mechanisms, and each mechanism +has to be justified as the only one available on its path rather than chosen for symmetry. + +The management interface adds a third fact of its own: it has exactly *one* port. Unlike the main HTTP +listener, the runtime's management configuration declares no separate SSL port and no +insecure-requests mode. There is no simultaneous plain listener to fall back to, so a management TLS +decision is necessarily a decision about the whole management surface -- health and metrics together. + +== Decision + +*Every server-TLS knob belongs to exactly one of three classes, and its class determines where it +lives. Policy lives in `gateway.yaml` under neutral names and reaches the runtime through exactly two +seams. Ports and material stay deployment-supplied. Build-time knobs leave the runtime surface +entirely.* + +This is +link:0011-gatewayyaml_exposes_JWKS_trust_and_egress_as_neutral_names_bound_by_the_deployment.adoc[ADR-0011]'s +ruling restated as a general rule and extended from the JWKS trust surface to the whole server-TLS +surface. ADR-0011 remains Accepted and is *extended*, not superseded: its JWKS-specific mechanisms are +unchanged, and this decision generalises the principle they instantiate. + +=== The three-way classification + +[cols="1,3", options="header"] +|=== +| Class | Rule + +| *Policy* +| Named neutrally in `gateway.yaml`. Covers the protocol floor, the cipher allowlist, ALPN, mTLS, and + whether the management interface serves over TLS. No `gateway.yaml` key, and no part of the JSON + schema that validates it, names the runtime. + +| *Deployment-bound* +| Supplied by the deployment and never promoted into `gateway.yaml`. Covers certificate and private + key material, trust material, and *every* port. Neutral deployment-side names the product already + owns stay where they are; runtime keys keep their environment-variable contract. + +| *Build-time* +| Lives in the build configuration and never appears in the runtime configuration surface at all. +|=== + +The classification is not a filing convention. It is what makes the ordinal rule below safe. + +=== Policy reaches the runtime through exactly two seams + +[cols="1,2,2", options="header"] +|=== +| Listener | Seam | Why it is the only one available + +| Terminated main HTTPS listener +| An `HttpServerOptionsCustomizer` bean mapping the resolved `tls` block onto the listener's enabled + protocol set, cipher allowlist and ALPN protocols. +| The runtime enumerates customizer beans from the CDI container *after* building the SSL options, so + the customizer is the last writer and its result is what the listener starts with. Declaring the + same policy through the runtime's TLS-registry keys does not work on this gateway, because the + registry is consulted only when the certificate also comes from it. + +| Management interface +| A SmallRye `ConfigSource` parsing `gateway.yaml` and projecting the management TLS policy onto the + runtime key that governs it. +| The management interface is initialised on a path that never enumerates `HttpServerOptionsCustomizer` + beans. The only lever left on that path is the configuration the recorder reads, so a config source + is the seam by elimination, not by preference. +|=== + +Neither seam is interchangeable with the other. The customizer cannot reach management; the config +source is the wrong instrument for the terminated listener, where a live bean can observe what the +configuration actually produced. + +The policy source cannot read the bound configuration model. A `ConfigSource` is constructed while the +configuration system itself is being built, long before CDI exists, so the model, its loader and its +validator are all unavailable to it. It therefore re-reads `gateway.yaml` directly and parses only the +policy blocks it projects from -- a deliberate, bounded duplication of a small slice of parsing, not a +second configuration pipeline. The authoritative parse, schema validation and semantic validation +still happen exactly once, later. A malformed or absent document is ignored at this stage on purpose: +the loader re-encounters the same failure moments later and reports it as a located, collected error, +and failing earlier would replace that diagnostic with a context-free failure from inside +configuration-system construction. + +=== The projecting source is single-ordinal and projects policy keys only + +The config source sits at an ordinal above the environment-variable source. It has to: a policy named +in `gateway.yaml` must take effect, and an ordinal at or below the environment's would let a +deployment variable silently win over the document the operator wrote. + +Outranking the environment for *everything* would be an operator surprise, and the obvious remedy -- a +second source at a lower ordinal carrying the keys the environment should still win -- is rejected. +Two sources with two precedences is a rule no operator can hold in their head and no document can +state without a table of exceptions. + +The classification above is what makes the second source unnecessary. The source projects *policy keys +only*: never a port, never trust material, never key material. Nothing an operator supplies from the +environment is in the projected set, so nothing can be outranked by surprise. A single ordinal is +therefore both sufficient and safer, and policy-keys-only is a standing invariant of the source rather +than a description of its current contents. + +One hard prohibition falls out of this and is worth stating separately, because its failure mode is +silent in the dangerous direction: *the source must never project key material into the default TLS +registry bucket.* The runtime's management-interface lookup falls back to the default registry bucket +when no configuration name is selected, and takes that fallback only when the default bucket actually +carries key material. This project's default bucket is empty -- the server certificate arrives through +the HTTP-SSL certificate keys -- so management TLS is exactly what the management configuration says +it is. Populating the default bucket from anywhere would switch the management interface to HTTPS +through a path no configuration file mentions. This is asserted by test, not assumed. + +=== Management HTTPS is the default, and the opt-out is runtime-native + +The management interface serves over TLS by default, on the deployment-bound management port. The +posture is secure-by-default and is deliberately not inverted to the runtime's own plain-by-default +shape. + +Taking management to plain HTTP stays possible, because running operations endpoints in the clear +behind a trusted boundary is a legitimate deployment, but it stops being silent. There are two doors +onto it, and they are the same lever: + +* *The neutral door* -- declaring the management TLS policy off in `gateway.yaml`, which the config + source projects onto the runtime's management TLS-configuration-name key, selecting a named TLS + bucket that carries no key material. +* *The deployment door* -- setting that same runtime key directly as an environment variable. + +Because the neutral key projects *nothing* when it is absent or set to the secure value, the +deployment door keeps working unchanged. The two are alternative routes onto one key, not two +competing mechanisms with a precedence puzzle between them. + +The mechanism itself is runtime-native rather than bespoke. Selecting a bucket with no key material +*is* a disable: the named lookup resolves, the key material is null-guarded rather than throwing, and +the recorder swaps in the plain options because no key/certificate options were produced. It is +runtime configuration, so the same native image serves both postures with no rebuild, and misuse is +legible -- an unresolvable name fails the boot with an error naming the exact missing property. + +The downgrade is *audited, not refused*. A startup observer emits a loud `WARN` naming what the +downgrade breaks. The audit is keyed on the *observed effective state* -- the TLS material the listener +actually resolved -- rather than on any configuration key, because an audit keyed on a key reports a +comfortable fiction the moment that key is renamed, superseded, or reached by a different route. + +=== The neutral management block declares no port + +The neutral management block carries TLS policy only. It declares no port, and a port written there is +refused at boot with a message naming the runtime property and environment variable that do own it. + +The omission is structural, not cosmetic. A management port declared in `gateway.yaml` could only +reach the runtime through the same projecting source, whose ordinal is above the environment's -- so it +would silently override the deployment's own port variable, which is exactly the surprise the ordinal +rule exists to prevent. The only way to let the environment win would be the second, lower-ordinal +source that is rejected above. Refusing the key with an actionable message is the alternative to +silently ignoring it: the deliberate absence becomes discoverable by the operator rather than +presenting as a key that does nothing. + +=== Named consequences, so the boundary is auditable + +A classification rule is only enforceable if its exceptions are named. Three consequences are part of +the decision: + +* *The terminated listener's port and its plain-HTTP handling stay deployment-bound.* Promoting the SSL + port into `gateway.yaml` would break the + link:0017-Accept-time_SNI_split_at_a_dedicated_front_listener_passthrough_relays_opaquely_at_L4_everything_else_terminates_internally.adoc[ADR-0017] + SNI split, in which a deployment that enables passthrough gives the public TLS port to the front + listener and moves the terminated listener to an internal port through that very variable. A + `gateway.yaml`-owned port could not express that topology. +* *The management port stays deployment-bound*, for the ordinal reason above. +* *The native-image SSL knob is build-time and leaves the runtime surface entirely.* It is fixed when + the image is built, so `gateway.yaml` could never make it take effect and the runtime properties file + would only make it look like a runtime key. It is declared in the build's `native` profile -- the + configuration that is active exactly when the knob has meaning. + +=== Scope: policy applies to the terminated listener + +The TLS floor, cipher allowlist and ALPN policy govern the *terminated* listener. The SNI front +listener of +link:0017-Accept-time_SNI_split_at_a_dedicated_front_listener_passthrough_relays_opaquely_at_L4_everything_else_terminates_internally.adoc[ADR-0017] +terminates nothing -- it reassembles the ClientHello, reads the SNI, and either relays opaquely at L4 +or hands the still-encrypted stream on -- so it neither applies nor could apply a cipher allowlist. For +a passthrough hostname the backend's own TLS policy is the effective one, end to end. Scoping the +policy to the terminated listener is therefore a statement of what the policy *can* mean, not a +limitation that more code could lift. + +== Consequences + +=== Positive + +* Declared TLS policy is in force. The floor and the cipher allowlist reach the listener through an + asserted seam, so the configuration surface no longer claims an enforcement it does not perform. +* One document states the gateway's TLS posture. An operator or auditor reads the policy in one place, + in the product's vocabulary, instead of reconstructing it from runtime keys whose effectiveness + depends on how the certificate happens to be supplied. +* The classification makes new knobs decidable. "Is this policy, deployment-bound, or build-time?" has + an answer before the key is named, so the surface grows by rule rather than by precedent. +* A single ordinal keeps precedence explainable: `gateway.yaml` wins for policy, the environment wins + for everything else, and there is no third case. +* The secure-by-default posture is preserved with a real escape hatch. The management downgrade is + reachable, runtime-native, settable at container start in JVM and native images alike, and loud. +* Ports and material stay out of the document operators commit, so the same `gateway.yaml` moves + across environments unedited and carries no secrets. + +=== Negative + +* Policy is bound by two different mechanisms. A maintainer changing the surface must know which + listener a key targets and therefore which seam carries it; the seams are not interchangeable. +* The config source parses `gateway.yaml` a second time, before the authoritative loader runs. The + duplication is bounded to the policy blocks and deliberately silent on failure, but it is a second + reader of the same document and must stay in step with the first. +* The management downgrade is all-or-nothing. Because the management interface has exactly one port, + taking it off TLS takes health and metrics off TLS together; there is no partial posture. +* Two doors onto the management TLS decision means a reader of `gateway.yaml` alone cannot tell whether + a deployment variable has selected the downgrade. The startup `WARN` on observed effective state is + the compensating mechanism. + +=== Risks + +* *A future key projected outside the policy class.* The whole precedence argument rests on the + projected set containing no deployment-bound key. A single port or key-material addition to the + source would silently invert the environment's precedence. Mitigated by asserting the forbidden key + classes absent by name, and by pinning the projected set's cardinality so an addition is a visible + diff. +* *Silent HTTPS inheritance on the management interface.* Declaring a default TLS registry bucket + anywhere would make management inherit HTTPS through the registry fallback, with no configuration + file mentioning it. Mitigated by an explicit assertion that no default bucket is declared. +* *Drift between the two `gateway.yaml` readers.* The pre-configuration parse and the authoritative + loader read the same document with different machinery. Mitigated by keeping the early parse narrow + -- only the policy blocks, only the keys it projects -- so the overlap it must keep in step with is + small. +* *An operator reaching for the wrong disable.* Disabling the management interface outright is not the + way to get a plain-HTTP probe endpoint: it is build-time fixed, so it cannot be changed on a built + image, and it moves health and metrics onto the main TLS-terminated router -- producing an HTTPS probe + endpoint rather than a plain one. Mitigated by documenting the trap where the opt-out is documented. + +== Alternatives Considered + +=== Leave TLS policy in raw runtime keys + +Keep the protocol floor and cipher allowlist in the runtime properties file under the runtime's own key +names and add no neutral surface at all. + +Rejected because those keys are inert on this gateway. The runtime consults its TLS-registry +configuration only when the certificate is supplied through the registry, and this gateway supplies it +through the HTTP-SSL certificate keys, so the declared floor and allowlist never reach the listener. +Retaining them would preserve a configuration surface that reads as enforcement while enforcing +nothing -- the worst available outcome for a security gateway, and strictly worse than declaring no +policy at all. + +=== Reference the runtime's named TLS configuration directly from gateway.yaml + +Let `gateway.yaml` carry the runtime's own TLS-configuration name, removing the projecting source. + +Rejected on the same layering ground +link:0011-gatewayyaml_exposes_JWKS_trust_and_egress_as_neutral_names_bound_by_the_deployment.adoc[ADR-0011] +rejected it for the JWKS surface: it makes the product's configuration language depend on the identity +of the framework beneath it, so a change of runtime invalidates documents the product told operators to +write. The projecting source is the bounded price of keeping the configuration contract independent of +its substrate. + +=== A second, lower-ordinal config source + +Split the projection across two sources -- one above the environment for keys `gateway.yaml` must win, +one below it for keys the environment should still win. + +Rejected because it makes precedence a per-key property that no operator can predict and no document +can state without an exception table. The three-way classification removes the need entirely: with only +policy keys projected, there is no key for which the environment ought to win, so there is nothing for +a second source to carry. + +=== Promote ports into gateway.yaml + +Treat ports as part of the neutral surface, so one document describes the whole listener topology. + +Rejected on two independent grounds. Structurally, a projected port would outrank the deployment's own +port variable at the single ordinal, which is the operator surprise the ordinal rule exists to prevent. +Concretely, the ADR-0017 SNI split works by having a deployment move the terminated listener to an +internal port through that variable -- a topology a `gateway.yaml`-owned port could not express. + +=== A bespoke gateway.yaml key that disables management TLS by blanking the certificate + +Introduce a product-specific acknowledgement key whose presence causes the projecting source to emit +blank management certificate paths, forcing the listener to start plain. + +Rejected because it is a bespoke mechanism where a runtime-native one exists, and because it inverts +the audit relationship. It makes the product responsible for a certificate-blanking trick whose +correctness depends on internal runtime behaviour, and it would key the startup warning on the +product's own declaration rather than on what the listener actually resolved -- so the warning would +report the declaration, not the reality, and would go quiet the moment the downgrade arrived by any +other route. + +=== Refuse to boot on a management TLS downgrade + +Treat plain-HTTP management as a configuration error and fail the boot. + +Rejected because plain-HTTP management behind a trusted boundary is a legitimate deployment, and a +gateway that refuses it forces operators into a worse workaround -- most likely disabling the +management interface entirely, which moves health and metrics onto the TLS-terminated main router and +is build-time fixed besides. A loud, effective-state-keyed `WARN` keeps the deployment possible while +removing its silence, which is the property that actually matters. + +=== A validation rule for the refused management port + +Reject a `management.port` key through the configuration validator rather than in the JSON schema. + +Rejected because the rule would be unreachable. Loading returns on the first schema failure and never +reaches the validation stage, so a validator rule for a key the schema already rejects can never run. +Declaring the key in the schema solely in order to refuse it is what puts the actionable message at the +key's own location, rather than surfacing a generic unknown-key error against its parent. + +=== The runtime's management TLS-configuration-name key, considered and adopted + +Selecting a named TLS configuration for the management interface was initially rejected on two grounds: +that such a key can only *select* a TLS configuration and never *disable* TLS, and that using it would +relocate deployment certificate material into a product-declared bucket. + +Both objections fail against the mechanism as it actually behaves, and the key is therefore adopted as +the opt-out described above. Selecting a bucket that carries no key material *is* a disable -- the lookup +resolves, the absent key material is null-guarded rather than throwing, and the recorder substitutes +the plain options because no key/certificate options were produced. The second objection does not arise +for the same reason: the selected bucket is empty of material by construction, so no certificate is +relocated anywhere. Recording the reversal matters because both objections are plausible readings of +the key's name, and a future reader who re-derives them would otherwise re-reject a mechanism that +works. + +The unifying principle across these alternatives: a configuration surface earns its neutrality by being +*bound*, and it earns its precedence by being *narrow*. Options that add a name without a binding, or a +binding without a bounded key set, fail in one of those two directions. + +== References + +* link:0011-gatewayyaml_exposes_JWKS_trust_and_egress_as_neutral_names_bound_by_the_deployment.adoc[ADR-0011] + -- the neutral-name ruling for the JWKS trust and egress surface, which this decision extends to the + whole server-TLS surface +* link:0017-Accept-time_SNI_split_at_a_dedicated_front_listener_passthrough_relays_opaquely_at_L4_everything_else_terminates_internally.adoc[ADR-0017] + -- the SNI split whose port-ownership contract requires the terminated listener's port to stay + deployment-bound, and whose front listener terminates nothing +* link:0005-module-structure.adoc[ADR-0005] -- the framework-bound edge seam that keeps + runtime-specific assembly at the boundary rather than in the framework-agnostic core +* link:../configuration.adoc[Configuration reference] -- the `tls` and `management` field contracts and + the per-key verdict table naming every disposition +* link:../architecture.adoc[Architecture] -- the two binding seams and the ordering constraint that the + projecting config source runs before CDI +* link:../user/tls-edge.adoc[TLS edge (operator guide)] -- the neutral TLS policy keys and the + plain-HTTP management opt-out diff --git a/doc/architecture.adoc b/doc/architecture.adoc index 02dbdf7b..4b9527f7 100644 --- a/doc/architecture.adoc +++ b/doc/architecture.adoc @@ -94,6 +94,64 @@ terminated listener. Selection is *fail-closed* (GW-06): anything not positively passthrough is terminated, never relayed. The front-listener internals and the fail-closed rationale are documented in the link:development/tls-edge.adoc[developer TLS-edge topic]. +[[_server_tls_policy_binding]] +=== Server-TLS Policy Binding (Two Seams) + +`gateway.yaml` is the single effective source of server-TLS *policy* +(link:adr/0025-The_whole_server-TLS_surface_is_neutral_in_gatewayyaml_and_bound_by_exactly_two_seams.adoc[ADR-0025]). +Reaching the runtime takes *two different mechanisms*, because the two listeners do not share a +seam. This is a structural fact of the runtime, not a design preference: + +[cols="1,2,2", options="header"] +|=== +| Listener | Seam | Why it is the only one available + +| Terminated main HTTPS listener +| `TlsServerCustomizer`, an `HttpServerOptionsCustomizer` bean mapping `tls.min_version`, + `tls.cipher_suites` and `tls.alpn` onto the listener's enabled protocol set, cipher allowlist and + ALPN protocols. `MtlsServerCustomizer` is its sibling for `tls.mtls`. +| Quarkus enumerates customizer beans from the CDI container in `doServerStart` *after* + `createSslOptions` has run, so the customizer is the last writer and its result is what the + listener starts with. Declaring the same policy through `quarkus.tls.*` does not work here: that + registry is consulted only when the certificate also comes from it, and this gateway supplies its + certificate through `quarkus.http.ssl.certificate.*`. + +| Management interface +| `NeutralTlsConfigSource`, a SmallRye `ConfigSource` projecting `management.tls.enabled: false` + onto `quarkus.management.tls-configuration-name`. +| `VertxHttpRecorder.initializeManagementInterface` never enumerates `HttpServerOptionsCustomizer` + beans. The only lever left on that path is the configuration the recorder reads, so a config + source is the seam *by elimination*, not by preference. +|=== + +*The projecting source runs before CDI exists -- an ordering constraint, not an implementation +detail.* A `ConfigSource` is instantiated while the configuration system itself is being built, long +before the CDI container starts. `NeutralTlsConfigSource` therefore cannot inject `GatewayConfig`, +its loader, or its validator: none of them exist yet. It re-reads `gateway.yaml` directly with +SnakeYAML (under the same expansion-bomb caps as the authoritative loader, +link:adr/0010-YAML_expansion_limits_enforced_in_a_compose-only_pre-pass_before_bind.adoc[ADR-0010]) +and parses only the `management` block. The consequences are deliberate: + +* *The duplication is bounded and one-directional.* The authoritative parse, schema validation and + semantic validation still happen exactly once, later, in `ConfigLoader` and `ConfigValidator`. The + early pass reads one block and emits at most one key. +* *A malformed or absent document degrades silently here.* The loader re-encounters the same failure + moments later and reports it as a located, collected `ConfigError` that fails the boot. Throwing + from inside configuration-system construction would replace that precise diagnostic with a + context-free failure. +* *An unexpected shape reads as "not explicitly disabled".* Because this parse precedes schema + validation, a `management` block that is not a map, or an `enabled` that is not a boolean, must not + be guessed at -- keeping TLS on is the fail-safe direction while the loader prepares the real error. + +*The source is single-ordinal and projects policy keys only.* Its ordinal sits above the +environment-variable source's, because a policy named in `gateway.yaml` must take effect rather than +being silently overridden by a deployment variable. That is safe only because the projected set +contains *no* deployment-bound key: never a port, never trust material, and never +`quarkus.tls.key-store.*` -- populating the default TLS registry bucket would make the management +interface inherit HTTPS through `registry.getDefault()`, a path no configuration file mentions. A +second, lower-ordinal source is rejected; the policy-keys-only constraint is what makes it +unnecessary. + === Configuration Subsystem Configuration is loaded once at startup from static files (see link:configuration.adoc[Configuration Model]), diff --git a/doc/configuration.adoc b/doc/configuration.adoc index a578cf04..8a219422 100644 --- a/doc/configuration.adoc +++ b/doc/configuration.adoc @@ -125,8 +125,8 @@ file-and-JSON-pointer error before any immutable value object is built: | `schema/gateway.schema.json` | The global `gateway.yaml` document -- `version` (the only required key), `metadata`, `tls`, - `security_headers`, `security_defaults`, `allowed_methods`, `anchors`, `upstream_defaults`, - `forwarded`, `edge_hardening`, `token_validation` and `oidc`. + `management`, `security_headers`, `security_defaults`, `allowed_methods`, `anchors`, + `upstream_defaults`, `forwarded`, `edge_hardening`, `token_validation` and `oidc`. | `schema/endpoint.schema.json` | A single `endpoints/*.yaml` file -- the `endpoint` block (`id`, `enabled`, `base_url`, @@ -159,8 +159,13 @@ version: 1 # config schema version; unknown values are r metadata: config_version: "2026-07-10.1" # audit stamp only (see "Configuration Mode") -tls: # connections are TLS-terminated except listed SNIs +tls: # TLS POLICY for the terminated listener. Neutral names only: + # ports and certificate/trust material stay deployment- + # supplied and never appear here (ADR-0025) min_version: "1.3" + cipher_suites: [] # OPTIONAL allowlist, e.g. + # [TLS_AES_256_GCM_SHA384, TLS_AES_128_GCM_SHA256]. + # Omit/empty => the runtime's own suite selection alpn: [h2, http/1.1] # HTTP/2 (also carries gRPC) + HTTP/1.1 passthrough_sni: {} # SNI hostname -> topology alias, relayed at L4 without # decryption, e.g. { legacy.example.com: LEGACY } @@ -168,6 +173,14 @@ tls: # connections are TLS-terminated except liste enabled: false client_ca: /etc/sheriff/ca.pem +management: # TLS policy for the management interface (health/metrics on + # their own port). POLICY ONLY -- this block declares NO + # port; the port is deployment-bound (see "management") + tls: + enabled: true # HTTPS is the default and the shipped posture. false takes + # the WHOLE management surface to plain HTTP and emits a + # loud boot WARN (ApiSheriff-115) + security_headers: # response-header middleware (global default) hsts: { max_age: 31536000, include_subdomains: true } content_type_nosniff: true @@ -791,16 +804,46 @@ the endpoint/route `anchor` membership field), `auth` (link:#_auth[`auth`]), passthrough (mapped SNIs) coexist on the same listener. | `min_version` -| Minimum negotiated TLS version for terminated connections; `1.3` recommended, `1.2` floor. +| Minimum negotiated TLS version for the *terminated* listener; `1.3` recommended, `1.2` floor. + A `1.2` floor enables TLSv1.2 and TLSv1.3; a `1.3` floor enables TLSv1.3 only. Any other value + fails the boot rather than being silently dropped. Omit the key to leave the runtime's own + default protocol set untouched. + +| `cipher_suites` +| *Optional* cipher allowlist for the *terminated* listener, given as JSSE suite names (e.g. + `TLS_AES_256_GCM_SHA384`). Each listed suite is enabled; an omitted or empty list leaves the + runtime's own suite selection untouched, which is the recommended default -- an allowlist is a + commitment to maintain it as suites are deprecated. Declaring suites that no enabled protocol + offers yields a listener that negotiates nothing, so keep the list consistent with `min_version`. | `alpn` -| Advertised ALPN protocols. `h2` also carries gRPC ("gRPCS" when TLS-terminated). +| Advertised ALPN protocols for the *terminated* listener. `h2` also carries gRPC ("gRPCS" when + TLS-terminated). Accepted values are `h2`, `http/1.1` and `http/1.0`; any other identifier fails + the boot. An omitted or empty list leaves ALPN as the runtime configured it. | `mtls.*` | Require and verify client certificates against the given CA (terminated connections only -- on passthrough the gateway never participates in the handshake). |=== +[[_tls_scope_limit]] +*Scope limit -- these keys govern the terminated listener only.* `min_version`, `cipher_suites` +and `alpn` are applied to the listener the gateway itself terminates. They do *not* and cannot +apply to a passthrough connection: the SNI front listener terminates nothing -- it reassembles the +ClientHello, reads the SNI, and either relays the still-encrypted stream at L4 or hands it to the +terminated listener (link:adr/0017-Accept-time_SNI_split_at_a_dedicated_front_listener_passthrough_relays_opaquely_at_L4_everything_else_terminates_internally.adoc[ADR-0017]). +For a passthrough hostname the *backend's* own TLS policy is the effective one, end to end. This is +a statement of what the policy can mean, not a limitation more configuration could lift. + +*These keys are the single effective source.* The three policy keys above are bound onto the +terminated listener by a customizer that runs after the runtime has built its SSL options, so what +is written here is what the listener starts with. There is deliberately no raw `quarkus.tls.*` +policy key left in `application.properties` for them to compete with -- see +link:#_server_tls_key_verdicts[Server-TLS key verdicts] for the disposition of every key that used +to live there, and +link:adr/0025-The_whole_server-TLS_surface_is_neutral_in_gatewayyaml_and_bound_by_exactly_two_seams.adoc[ADR-0025] +for the classification rule that decides where a new knob belongs. + *How SNI selection works.* Server Name Indication (RFC 6066) is the hostname the client sends in the *TLS ClientHello* -- in cleartext, before any encryption is established -- precisely so that a server fronting multiple hosts can decide how to handle the connection without decrypting anything. @@ -851,6 +894,128 @@ trust-root-replacement blast radius -- is covered in the link:user/tls-edge.adoc topic]; the front-listener architecture and the fail-closed (GW-06) rationale are covered in the link:development/tls-edge.adoc[developer TLS-edge topic]. +[[_management]] +=== `management` + +The optional global `management` block carries the TLS policy of the *management interface* -- the +separate port serving health and metrics. It follows the same rule as `tls`: neutral names for +*policy*, nothing else. + +[cols="1,3"] +|=== +| Field | Meaning + +| `tls.enabled` +| Whether the management interface serves over TLS. *Defaults to `true`*, which is also the shipped + posture -- HTTPS on the management port is the secure default and stays the default. Setting it to + `false` selects a key-less named TLS bucket, so the management listener starts on plain HTTP. + +| (no `port` key) +| *This block declares no port, deliberately.* A `management.port` written in `gateway.yaml` is + *refused at boot* with a message naming `quarkus.management.port` / + `QUARKUS_MANAGEMENT_PORT` -- the property that does own it -- and + link:adr/0025-The_whole_server-TLS_surface_is_neutral_in_gatewayyaml_and_bound_by_exactly_two_seams.adoc[ADR-0025]. + The refusal is implemented in `schema/gateway.schema.json`: the `port` property is declared solely + in order to fail, so the actionable sentence lands at the `/management/port` JSON pointer rather + than surfacing as a generic unknown-key error against the parent object. The port default is + `9000`. +|=== + +*Why there is no port here.* The management block reaches the runtime through a configuration +source whose ordinal sits deliberately above the environment's, so a policy named in `gateway.yaml` +is not silently overridden by a deployment variable. A *port* projected at that ordinal would in +turn silently override `QUARKUS_MANAGEMENT_PORT` -- the operator surprise the ordinal rule exists to +prevent -- and the only way to let the environment win would be a second, lower-ordinal source that +ADR-0025 rejects. The omission is therefore structural, and it is made discoverable by refusing the +key rather than ignoring it. + +*The single-port constraint: a downgrade takes the whole surface.* Unlike the main HTTP listener, +the management interface has exactly *one* port -- there is no management `ssl-port` and no +management `insecure-requests` mode, so there is no simultaneous HTTPS listener to fall back to. +`tls.enabled: false` therefore takes health *and* metrics to plain HTTP in their entirety, and every +consumer probing that port over HTTPS breaks. That is why the downgrade is *audited with a loud +boot `WARN`* (`ApiSheriff-115`, see link:LogMessages.adoc[Log Messages]) rather than passing +silently -- and why it is a `WARN` and never a boot refusal: plain-HTTP management behind a trusted +network boundary is a legitimate deployment. + +*Two doors, one lever.* The neutral key above and the deployment-level +`QUARKUS_MANAGEMENT_TLS_CONFIGURATION_NAME` environment variable land on the *same* runtime key. +`tls.enabled: true` and an omitted block both project *nothing*, which is exactly what keeps the +deployment variable a working route. The operator guidance -- when the downgrade is legitimate, and +why `quarkus.management.enabled=false` is *not* the way to get a plain probe endpoint -- lives in +the link:user/tls-edge.adoc[operator TLS-edge topic]. + +[[_server_tls_key_verdicts]] +=== Server-TLS key verdicts + +`gateway.yaml` is the single effective source of server-TLS *policy*. The table below records the +disposition of every raw `quarkus.*` server-TLS key that used to live in `application.properties`, +classified by +link:adr/0025-The_whole_server-TLS_surface_is_neutral_in_gatewayyaml_and_bound_by_exactly_two_seams.adoc[ADR-0025]'s +three-way rule -- *policy* (neutral in `gateway.yaml`), *deployment-bound* (ports and material, +supplied by the deployment), *build-time* (fixed when the image is built, never in the runtime +surface). The two exceptions to the "no raw server-TLS policy key remains" bar are named explicitly; +an undocumented exception would be a silent trim. + +[cols="2,1,1,3", options="header"] +|=== +| Key | Class | Disposition | Reason and destination + +| `quarkus.tls.protocols` +| policy +| deleted +| Replaced by `tls.min_version`. The key never reached the listener: the runtime consults its TLS + registry only when the certificate also comes from the registry, and this gateway supplies its + certificate through `quarkus.http.ssl.certificate.*` -- so the declared floor was inert. + +| `quarkus.tls.cipher-suites` +| policy +| deleted +| Replaced by `tls.cipher_suites`, inert for the same reason. + +| `quarkus.tls.alpn` +| policy +| deleted +| Replaced by `tls.alpn`, inert for the same reason. + +| `quarkus.http.ssl.client-auth` +| policy +| deleted +| Replaced by `tls.mtls`, which was already bound programmatically -- the customizer runs after the + runtime builds the SSL options and overwrites whatever this key had set, so `gateway.yaml` was + already the effective source and the key was misleading. + +| `quarkus.ssl.native` +| *build-time* +| *exception 1 -- relocated* +| Declared in `api-sheriff/pom.xml`'s `native` profile. It is a GraalVM knob fixed when the image is + built, so `gateway.yaml` could never make it take effect and `application.properties` would only + make it look like a runtime key. It lives with the other build-time native properties, in the + profile that is active exactly when the knob has meaning. + +| `quarkus.http.ssl-port` +| *deployment-bound* +| *exception 2 -- retained* +| Stays in `application.properties` as the default (`8443`), overridden per deployment by + `QUARKUS_HTTP_SSL_PORT`. Promoting it would break the + link:adr/0017-Accept-time_SNI_split_at_a_dedicated_front_listener_passthrough_relays_opaquely_at_L4_everything_else_terminates_internally.adoc[ADR-0017] + SNI split, where a deployment that enables `tls.passthrough_sni` moves the terminated listener to + the internal loopback port through exactly this variable -- a topology a `gateway.yaml`-owned port + could not express. + +| `quarkus.http.insecure-requests` +| *deployment-bound* +| *exception 2 -- retained* +| Same class: a port/exposure knob the deployment owns, not TLS policy. +|=== + +Certificate, key and trust *material* is deployment-bound by the same rule and is likewise never +promoted into `gateway.yaml` -- it arrives through `quarkus.http.ssl.certificate.*` and the +`QUARKUS_*` environment contract, exactly as the JWKS trust profiles of +link:adr/0011-gatewayyaml_exposes_JWKS_trust_and_egress_as_neutral_names_bound_by_the_deployment.adoc[ADR-0011] +do. The full operator-facing split of what stays deployment-bound versus what `gateway.yaml` owns is +in link:user/environment-variable-overrides.adoc[Environment-variable overrides]. + [[_protocols]] === `protocol` diff --git a/doc/user/README.adoc b/doc/user/README.adoc index a115ead4..3527a95c 100644 --- a/doc/user/README.adoc +++ b/doc/user/README.adoc @@ -33,11 +33,14 @@ layer. Links to the Configuration Reference for the field contract and to the Architecture document for the gRPC error mapping. -| link:tls-edge.adoc[TLS Edge -- Passthrough SNI and mTLS] -| Configuring `tls.passthrough_sni` (relay selected hostnames at L4 without terminating) and - `tls.mtls` (require-and-verify a client certificate on terminated connections), plus the - emergency trust-root-replacement blast radius every operator must understand before relying on - mTLS. Links to the Configuration Reference for the field contract. +| link:tls-edge.adoc[TLS Edge -- Policy, Passthrough SNI, mTLS and the Management Interface] +| Configuring the TLS policy keys (`tls.min_version`, `tls.cipher_suites`, `tls.alpn`) and their + terminated-listener scope limit; `tls.passthrough_sni` (relay selected hostnames at L4 without + terminating) and `tls.mtls` (require-and-verify a client certificate on terminated connections), + plus the emergency trust-root-replacement blast radius every operator must understand before + relying on mTLS; and the management interface -- HTTPS by default, the single-port constraint, the + two doors onto the plain-HTTP opt-out, and why `quarkus.management.enabled=false` is not one of + them. Links to the Configuration Reference for the field contract. | link:bff-session.adoc[Server-Session BFF -- Operator Setup] | A task-oriented guide for standing up the server-session BFF (Variant 2): registering the OIDC @@ -51,6 +54,13 @@ layer. trade-off, decrypt-only key rotation, the cookie size budget, what IdP-driven destruction cannot do in this variant, the login-leg session affinity, and the forced re-login on a sealed-cookie format bump. Links to the Configuration Reference for the `oidc` key contract. + +| link:environment-variable-overrides.adoc[Environment-Variable Overrides] +| Which knobs the deployment owns and which `gateway.yaml` owns: the three-way classification + (policy / deployment-bound / build-time), the precedence rule and why it has no exceptions, the + enumerated deployment-bound variables (ports, certificate and trust material, the management TLS + selection), and an audit of where else the codebase offers a secure default with an override + route. Read it before assuming an environment variable can change a given behaviour. |=== == Scope of This Layer diff --git a/doc/user/environment-variable-overrides.adoc b/doc/user/environment-variable-overrides.adoc new file mode 100644 index 00000000..5a9d6786 --- /dev/null +++ b/doc/user/environment-variable-overrides.adoc @@ -0,0 +1,402 @@ += API Sheriff -- Environment-Variable Overrides +:toc: +:toclevels: 3 +:sectnums: + +Which knobs the *deployment* owns, which `gateway.yaml` owns, and which are fixed before either gets +a say. Read this page before assuming an environment variable can change a given behaviour -- for +several of the gateway's settings it cannot, and for a few the reason is structural rather than an +oversight. + +The rule this page documents is recorded in +link:../adr/0025-The_whole_server-TLS_surface_is_neutral_in_gatewayyaml_and_bound_by_exactly_two_seams.adoc[ADR-0025]; +the field-level contract for every `gateway.yaml` key is in the +link:../configuration.adoc[Configuration Reference]. + +== The three classes + +Every configuration knob belongs to exactly one of three classes, and its class decides where it +lives. Knowing the class answers "can I set this from the environment?" before you go looking. + +[cols="1,2,2", options="header"] +|=== +| Class | What it is | Where it lives + +| *Policy* +| A security or behavioural decision this gateway makes -- one that is the same in every environment + the same document is deployed into. The TLS protocol floor, the cipher allowlist, ALPN, mTLS, + whether the management interface serves over TLS, the inbound-filter mode, the admission budgets. +| `gateway.yaml`, under *neutral names*. No key in that document -- and no part of the JSON schema + that validates it -- names the underlying runtime, so the document stays portable. + +| *Deployment-bound* +| A value that is genuinely different per environment, or that is secret. Certificate and private + key material, trust anchors, and *every port*. +| Supplied by the deployment: environment variables and mounted files. Deliberately *not* promoted + into `gateway.yaml`, so the document you commit carries no secrets and no environment specifics. + +| *Build-time* +| A knob that is fixed when the artifact is built and cannot be changed on a built image. +| The build configuration (`api-sheriff/pom.xml`). It never appears in the runtime configuration + surface at all, because putting it there would only make it *look* settable. +|=== + +== The precedence rule (and why it has no exceptions) + +`gateway.yaml` policy is projected into the runtime by a configuration source whose ordinal sits +*above* the environment-variable source. In plain terms: *for a policy key, `gateway.yaml` wins over +an environment variable.* It has to -- otherwise a policy you wrote, reviewed and committed could be +silently overridden by a variable someone set on a container. + +That would be an unpleasant rule if it applied to everything, so it does not. *The projecting source +emits policy keys only.* It never emits a port, never emits trust material, and never emits key +material. Because no deployment-bound knob is ever in the projected set, nothing you set from the +environment can be outranked by surprise. + +The result is a rule with exactly two branches and no exception table: + +* *Policy* -> `gateway.yaml` wins. +* *Everything else* -> the environment wins, exactly as it always did. + +A second configuration source at a lower ordinal -- so that the environment could win for *some* +policy keys -- was considered and rejected. It would make precedence a per-key property no operator +could predict and no document could state without a list of special cases. + +== Deployment-bound: what you set from the environment + +=== Ports + +*Every port is deployment-bound*, without exception. None of them can be written in `gateway.yaml`, +and `management.port` is *refused at boot* if you try -- with an error naming the property that does +own it, so the omission is discoverable rather than silent. + +[cols="2,1,3", options="header"] +|=== +| Variable | Default | What it moves + +| `QUARKUS_HTTP_PORT` +| `8080` +| The plain-HTTP application listener. + +| `QUARKUS_HTTP_SSL_PORT` +| `8443` +| The *terminated* HTTPS listener. This is the variable the SNI passthrough topology moves: a + deployment that enables `tls.passthrough_sni` gives the public TLS port to the front listener and + sets this to the internal loopback port (`8444`). A `gateway.yaml`-owned port could not express + that split, which is the concrete reason this knob was never promoted. + +| `QUARKUS_MANAGEMENT_PORT` +| `9000` +| The management interface (health and metrics). See + link:tls-edge.adoc[TLS Edge] for why the neutral `management` block declares no port. + +| `SHERIFF_TLS_PUBLIC_PORT` +| `8443` +| The public port the SNI front listener binds when `tls.passthrough_sni` is non-empty. Neutral + name, deployment-supplied value -- port, therefore deployment-bound. + +| `SHERIFF_TLS_INTERNAL_HTTPS_PORT` +| `8444` +| The internal loopback port the terminated listener moves to under the passthrough topology. +|=== + +=== Certificate, key and trust material + +Material is deployment-bound for the same reason trust anchors are in +link:../adr/0011-gatewayyaml_exposes_JWKS_trust_and_egress_as_neutral_names_bound_by_the_deployment.adoc[ADR-0011]: +it is secret, environment-specific, and the runtime already implements loading, format handling and +rotation competently. Re-implementing that behind a neutral key would produce a second, weaker +implementation and put secrets in the document you commit. + +[cols="2,3", options="header"] +|=== +| Variable | Supplies + +| `QUARKUS_HTTP_SSL_CERTIFICATE_FILES` + + `QUARKUS_HTTP_SSL_CERTIFICATE_KEY_FILES` +| The terminated listener's server certificate and private key. + +| `QUARKUS_MANAGEMENT_SSL_CERTIFICATE_FILES` + + `QUARKUS_MANAGEMENT_SSL_CERTIFICATE_KEY_FILES` +| The management interface's certificate and key. Deliberately not set in the shipped configuration: + they are container paths that would break every non-container run. + +| `QUARKUS_TLS__TRUST__STORE_*` +| Trust anchors bound to a *logical trust-profile name* that `gateway.yaml` refers to by name only + (`jwks.tls_profile`). The document names the profile; the deployment binds it to material. + Copy the exact spelling from the shipped compose files rather than deriving it: a bucket name is a + configuration *map key*, and its environment-variable encoding is not a plain dot-to-underscore + substitution (the shipped stack uses `QUARKUS_TLS_DEFAULT_TRUST__STORE_P12_PATH`). +|=== + +[IMPORTANT] +==== +*Never declare a default `quarkus.tls.key-store.*` bucket -- anywhere.* The management interface +falls back to the *default* TLS registry bucket when no configuration name is selected, and takes +that fallback only when the default bucket actually carries key material. This project's default +bucket is empty (the server certificate arrives through `quarkus.http.ssl.certificate.*`), which is +what keeps management TLS exactly what the management configuration says it is. Populating the +default bucket would switch the management interface to HTTPS through a path no configuration file +mentions. +==== + +=== The management TLS selection + +[cols="2,3", options="header"] +|=== +| Variable | Effect + +| `QUARKUS_MANAGEMENT_TLS_CONFIGURATION_NAME` +| Selects a named TLS bucket for the management interface. Set to `plain-management` -- a bucket the + shipped configuration declares with *no key material* -- to take the management interface to plain + HTTP. This is the *deployment door* onto the same lever as `gateway.yaml`'s + `management.tls.enabled: false`; both land on the same runtime key. The neutral key projects + *nothing* when it is absent or `true`, which is precisely what keeps this variable working. +|=== + +A downgrade by either route emits `ApiSheriff-115` at `WARN` on boot, naming the port and what +breaks. See link:tls-edge.adoc[TLS Edge] for when the downgrade is legitimate, and for why +`QUARKUS_MANAGEMENT_ENABLED=false` is *not* an equivalent shortcut. + +=== Topology and endpoint enablement + +Topology aliases and endpoint enablement are also deployment-supplied, but through a different -- +and deliberately *visible* -- mechanism: an in-file `${VAR}` placeholder written in the configuration +document (link:../adr/0004-topology-indirection.adoc[ADR-0004] Amendment A1). An environment value +reaches these fields only through an explicit placeholder, never through an invisible lookup, so +reading the file tells you every override point. The conventional names are `TOPOLOGY_` and +`ENDPOINT__ENABLED`; the contract is in +link:../configuration.adoc#_topology[Configuration Reference -- Topology]. + +=== The configuration directory + +`SHERIFF_CONFIG_DIR` (default `config`) selects the directory holding `gateway.yaml`, +`endpoints/`, and `topology.properties`. It is necessarily deployment-bound -- it is the pointer to +the configuration itself, so it cannot live inside it. + +== Build-time: fixed before the environment gets a say + +These cannot be changed on a built image. If you find yourself reaching for one at deploy time, the +answer is a different mechanism, not a different variable. + +[cols="2,3", options="header"] +|=== +| Knob | Why it is build-time, and what to do instead + +| `quarkus.ssl.native` +| A GraalVM knob enabling SSL support in the native image; fixed when the image is built. It lives in + `api-sheriff/pom.xml`'s `native` profile -- the configuration that is active exactly when the knob + has meaning. `gateway.yaml` could never make it take effect, and putting it in + `application.properties` would only make it look like a runtime key. + +| `quarkus.management.enabled` +| Build-time fixed *and* a trap: setting it `false` does not give you a plain probe endpoint, it + moves health and metrics onto the main TLS-terminated router. Use the `plain-management` bucket + instead -- see link:tls-edge.adoc[TLS Edge]. +|=== + +== Audit: secure defaults and their override routes elsewhere + +The management-TLS pair above is one instance of a general shape: *a security-relevant default, plus +some route by which an operator can change it.* This section is a sweep of the rest of the runtime +surface asking the same question of each area -- what is the default, what is its security posture, +what is the override route, and is that route deployment-bound *by necessity* (a port, material, or a +build-time knob) or merely *by accident* (a policy knob that simply never got a neutral name). + +Read it as a map of what you can and cannot change, and of where the answer is "nothing, by design". + +=== Terminated-listener TLS policy + +[cols="1,1,1,2", options="header"] +|=== +| Setting | Default | Posture | Override route + +| `tls.min_version` +| runtime default (unset) +| The runtime's own floor applies until you declare one. Declaring `"1.2"` or `"1.3"` is the + stronger posture because it is *stated* rather than inherited. +| *Neutral* -- `gateway.yaml`. Deployment-bound by *accident* only in one narrow sense: a deployment + may still set `QUARKUS_HTTP_SSL_PROTOCOLS`, and that route works *until* `gateway.yaml` declares + `min_version`, at which point the neutral key wins by ordinal. Do not rely on both. + +| `tls.cipher_suites` +| runtime default (unset) +| The runtime's selection tracks upstream deprecation. An allowlist is *not* automatically stronger + -- it is a posture you then own and must maintain. +| *Neutral* -- `gateway.yaml`, and there is no raw key left to compete with it. + +| `tls.alpn` +| runtime default (unset) +| Not a security posture so much as a protocol-negotiation one. +| *Neutral* -- `gateway.yaml`. +|=== + +=== mTLS (`tls.mtls`) + +* *Default:* off. No client certificate is requested. +* *Posture:* off is the correct default -- mTLS is an opt-in trust model, not a hardening step, and + turning it on without staged client certificates locks out the entire fleet. +* *Override route:* *neutral*, `gateway.yaml` only. `tls.mtls.enabled` switches the terminated + listener to require-and-verify against `client_ca`. There is deliberately *no* + `quarkus.http.ssl.client-auth` key in the shipped configuration: the customizer runs after the + runtime builds the SSL options and overwrites whatever that key had set, so a deployment-level + client-auth value would be silently ineffective. Leaving it out is what stops that lie being told. +* *Deployment-bound by necessity?* The *trust anchor path* is, being material. The *decision* is not, + and is correctly neutral. +* *Operator caveat:* mTLS here is require-and-verify or off -- no optional/want mode -- and + `client_ca` is a single global anchor with no CRL or OCSP. Revocation is trust-root rotation, which + is all-or-nothing. See link:tls-edge.adoc[TLS Edge]. + +=== Listener topology and the SNI split + +* *Defaults:* `sheriff.tls.public-port` `8443`, `sheriff.tls.internal-https-port` `8444`. The SNI + front listener is *not started at all* unless `tls.passthrough_sni` is non-empty -- the default + topology is a single terminated listener with zero overhead. +* *Posture:* the split is fail-closed by construction. An empty, malformed, or unmatched SNI takes + the *terminated* path, never the passthrough one, so an ambiguous ClientHello cannot reach the more + permissive disposition. +* *Override route:* *split by class, correctly.* Which hostnames pass through is *policy* and lives + in `gateway.yaml`; the *ports* are deployment-bound and live in the environment. +* *Deployment-bound by necessity?* Yes -- these are ports, and the passthrough topology is precisely + the case that proves it: enabling passthrough requires moving the terminated listener to the + internal port, which only a deployment-supplied value can express. + +=== JWKS trust and egress + +* *Defaults:* `allowed_egress_hosts` absent (the secure default: a JWKS URL resolving to a loopback, + link-local, site-local, any-local, multicast or unique-local address is *refused*), and + `tls_profile` absent (the JVM default trust store). +* *Posture:* both defaults are the secure ones, and both widenings are opt-in and + default-preserving. `allowed_egress_hosts` is host-exact with no wildcard or suffix form, so the + widening cannot be broadened by a typo. +* *Override route:* *neutral*, `gateway.yaml` -- and this is the pattern the whole configuration + model follows (link:../adr/0011-gatewayyaml_exposes_JWKS_trust_and_egress_as_neutral_names_bound_by_the_deployment.adoc[ADR-0011]). + The document names a logical trust *profile*; the deployment binds that name to anchors. +* *Deployment-bound by necessity?* The anchors are (material). The profile *name* is not, and is + correctly neutral. An unresolvable profile *fails startup* rather than falling back to default + trust -- a trust decision that silently degrades is worse than one that refuses to start. + +=== Token validation + +* *Default:* the shipped default profile configures *no* issuer; deployments supply their own. +* *Posture:* the validator is assembled *eagerly at boot*, not lazily on the first bearer request, so + a misconfigured issuer -- an unresolvable trust profile, a missing JWKS source -- aborts startup + rather than surfacing later as a stream of token rejections that look like a client problem. This + is a deliberate fail-fast choice and it is load-bearing: an `@ApplicationScoped` producer is + injected as a lazy proxy, so the startup observer must actively invoke a method on it to force + assembly. Removing that call would silently restore lazy behaviour with every test still green. +* *Override route:* *neutral*, `gateway.yaml`'s `token_validation` block. +* *Deployment-bound by necessity?* No. Issuer identity, audience and JWKS location are policy and are + correctly neutral; only trust material is deployment-supplied. + +[NOTE] +==== +*Inconclusive -- the parallel `sheriff.token.issuers.*` property surface.* The gateway builds its own +validator from `gateway.yaml` and qualifies it, so it coexists with the unqualified validator the +token-validation extension builds from its own `sheriff.token.issuers.*` property surface. The +integration profile configures the latter, and its documented purpose there is the extension's *JWKS +readiness check*. What this audit could *not* establish by reading alone is what that readiness check +reports in a shipped deployment that configures no issuer on the property surface at all. Treat the +interaction between the two surfaces as unverified rather than assuming either that it is fine or +that it is broken; it wants an experiment, not a doc claim. +==== + +=== Inbound filter modes + +* *Default:* an omitted `security_defaults` block resolves to `strict`. This is a *tightening* + relative to earlier releases -- see the "Inbound Filter Modes" migration notes in the + link:README.adoc[Operator Guide index]. +* *Posture:* secure by default, and the weakest mode is bounded rather than open-ended. `none` is a + *partial* disable: it turns off exactly the url-parameter name/value validation and the per-route + pipeline re-run, and nothing else -- the pre-route floor, the body cap and the path allowlist all + still apply. It is additionally *refused at boot* on effectively-authenticated and BFF routes, so + the weakening cannot reach the surfaces where it would matter most. +* *Override route:* *neutral*, `gateway.yaml`, per route or anchor. There is no environment route, + and an unrecognised value is a hard boot failure with no fallback. +* *Deployment-bound by necessity?* No, and it should not be -- an inbound-validation posture that + could be relaxed by an environment variable would be a weakening with no audit trail in the + document under review. + +=== Request limits and admission budgets + +[cols="1,1,1,2", options="header"] +|=== +| Setting | Default | Posture | Override route + +| `edge_hardening.admission_cap` +| 2048 +| Bounds in-flight requests so a flood cannot spawn unbounded virtual threads; excess is refused + `503` before dispatch. +| *Neutral* -- `gateway.yaml`. Sizing depends on traffic shape, which is why the operator owns it. + +| `edge_hardening.websocket_relay_cap` +| a quarter of the effective `admission_cap` +| A relay holds its permit for the connection's lifetime, so without a second bound a modest number + of long-lived relays would starve ordinary HTTP traffic. The derived default keeps the + three-quarters-for-HTTP reservation at *every* admission cap. +| *Neutral* -- `gateway.yaml`. + +| Per-route `max_body_bytes` +| the resolved profile's preset +| The declared cap is the effective one; the framework floor is derived from it and validated + fail-closed at boot, so a declared cap can never be silently clipped by the framework + (link:../adr/0023-The_declared_max_body_bytes_cap_is_the_effective_one_and_a_breach_is_the_gateways_413.adoc[ADR-0023]). +| *Neutral* per route, with `QUARKUS_HTTP_LIMITS_MAX_BODY_SIZE` as the deployment-side *floor* that + must be at least as large. This is one place where a deployment value and a document value must be + kept consistent -- the boot check refuses the mismatch rather than clipping silently. + +| Header block, request line, chunk size, idle reap, drain window +| 16 KiB / 8 KiB / 16 KiB / 60 s / 25 s +| Fixed secure defaults that fail fast on abusive framing before a request is admitted. +| *None -- deliberately not operator-configurable.* These are protocol-safety bounds rather than + capacity choices, so there is no route to change them and no environment variable to look for. If + you are hunting for one, the answer is that it does not exist by design. +|=== + +=== Response headers and CORS + +* *Default:* CORS is *disabled unless configured*. The other response-header controls (HSTS, + nosniff, frame-deny) are declared explicitly in `gateway.yaml`. +* *Posture:* deny-by-default. A cross-origin SPA must enable CORS deliberately, with credentials, + rather than inheriting a permissive default. +* *Override route:* *neutral*, `gateway.yaml`, resolved at gateway -> anchor level only. Endpoints and + routes deliberately cannot override it, because response headers and the CORS preflight answer are + emitted *before* route selection -- there is no route yet whose policy could apply. +* *Deployment-bound by necessity?* No, and correctly so: an allowed-origin list is policy. + +=== What the sweep did not find + +No key in the shipped `application.properties` was found to be *inert* -- declared or bound with no +production consumer -- after the changes this document accompanies. The inert keys that motivated the +neutral surface (`quarkus.tls.protocols`, `quarkus.tls.cipher-suites`, `quarkus.tls.alpn`, +`quarkus.http.ssl.client-auth`) are gone, and their reappearance is now a failing build rather than a +review-time catch. The two neutral port keys (`sheriff.tls.public-port`, +`sheriff.tls.internal-https-port`) were checked and do have production consumers. + +That is a statement about this file at this revision, not a general guarantee. The sweep read the +shipped runtime properties, the TLS and configuration subsystems, and the operator-facing defaults +above; it did not exhaustively trace every `@ConfigProperty` in the codebase. + +== Quick answers + +[cols="2,3", options="header"] +|=== +| "Can I set ... from the environment?" | Answer + +| The TLS protocol floor or cipher suites +| No -- they are *policy*. Write `tls.min_version` / `tls.cipher_suites` in `gateway.yaml`. An + environment variable naming the same runtime knob would be outranked. + +| Whether management serves HTTPS +| Yes, via `QUARKUS_MANAGEMENT_TLS_CONFIGURATION_NAME` -- or in `gateway.yaml` via + `management.tls.enabled`. Both doors, one lever. Pick one. + +| Any port +| Yes -- every port is deployment-bound. `gateway.yaml` owns none of them. + +| Certificates, keys, trust anchors +| Yes -- all material is deployment-supplied. `gateway.yaml` names trust *profiles*, never material. + +| Native-image SSL support +| No -- build-time. It is set in the Maven `native` profile. +|=== diff --git a/doc/user/tls-edge.adoc b/doc/user/tls-edge.adoc index e4420b73..f4666cd1 100644 --- a/doc/user/tls-edge.adoc +++ b/doc/user/tls-edge.adoc @@ -1,14 +1,53 @@ -= API Sheriff -- TLS Edge: Passthrough SNI and mTLS += API Sheriff -- TLS Edge: Policy, Passthrough SNI, mTLS and the Management Interface :toc: :toclevels: 2 :sectnums: -An operator guide to the two TLS-edge features: *SNI passthrough* (relay selected hostnames at L4 -without terminating) and *mTLS* (require and verify a client certificate on terminated -connections). This topic describes how to *configure and operate* these keys; the authoritative +An operator guide to the TLS edge: the *TLS policy keys* (protocol floor, cipher allowlist, ALPN), +*SNI passthrough* (relay selected hostnames at L4 without terminating), *mTLS* (require and verify a +client certificate on terminated connections), and the *management interface* (health and metrics on +their own port). This topic describes how to *configure and operate* these keys; the authoritative field contract lives in the link:../configuration.adoc#_tls[Configuration Reference `tls` section], and the design rationale in the link:../development/tls-edge.adoc[developer TLS-edge topic]. +Everything on this page follows one rule, recorded in +link:../adr/0025-The_whole_server-TLS_surface_is_neutral_in_gatewayyaml_and_bound_by_exactly_two_seams.adoc[ADR-0025]: +*`gateway.yaml` names TLS policy; the deployment supplies ports and material.* Which side of that +line a given knob falls on -- and what to set when it is on the deployment side -- is enumerated in +link:environment-variable-overrides.adoc[Environment-variable overrides]. + +== TLS policy on the terminated listener + +Three keys govern how the gateway terminates TLS. All three are optional; omitting a key leaves the +runtime's own default in place. + +[source,yaml] +---- +tls: + min_version: "1.3" # "1.2" or "1.3" + cipher_suites: # OPTIONAL allowlist; omit to keep the runtime's selection + - TLS_AES_256_GCM_SHA384 + - TLS_AES_128_GCM_SHA256 + alpn: [h2, http/1.1] # h2 | http/1.1 | http/1.0 +---- + +Operating rules: + +* *`min_version` is a floor, not a pin.* `"1.2"` enables TLSv1.2 *and* TLSv1.3; `"1.3"` enables + TLSv1.3 only. Any other value fails the boot rather than being silently dropped. +* *A cipher allowlist is a maintenance commitment.* Omitting `cipher_suites` is the recommended + default -- the runtime's selection tracks upstream deprecations for you. Once you list suites, that + list is frozen until you edit it, and a list that no enabled protocol offers produces a listener + that negotiates nothing. Keep it consistent with `min_version`. +* *These keys govern the TERMINATED listener only.* They do not apply to a passthrough connection, + and no amount of configuration could make them: the front listener terminates nothing, so for a + passthrough hostname the *backend's* TLS policy is the effective one, end to end. If you need a + floor on a passthrough backend, set it on that backend. +* *These keys are effective, and they are the only source.* There is deliberately no raw + `quarkus.tls.*` policy key left in the shipped configuration for them to compete with. The + disposition of every key that used to live there is recorded in + link:../configuration.adoc#_server_tls_key_verdicts[Server-TLS key verdicts]. + == Passthrough SNI A passthrough entry maps an *SNI hostname* to a *topology alias*; every connection whose TLS @@ -80,3 +119,86 @@ Plan trust-root rotation as a coordinated event: stage the new client certificat *before* switching `client_ca`, because the switch is atomic and unforgiving. For a single compromised client where you cannot rotate the whole fleet immediately, the compensating control is network-level (drop the client at the perimeter), not certificate-level. + +== The management interface (health and metrics) + +Health and metrics are served on a *separate port* from application traffic -- port `9000` by +default -- so operations endpoints are not exposed on the public listener. *That port serves HTTPS +by default, and HTTPS stays the default.* + +=== The single-port constraint + +The management interface has exactly *one* port. Unlike the main HTTP listener there is no +management SSL port and no management "insecure requests" mode, so there is no simultaneous HTTPS +listener to fall back to. Any TLS decision here is a decision about the *whole* management surface: +turning TLS off takes health *and* metrics to plain HTTP together, and every consumer probing that +port over HTTPS -- readiness gates, scrapers, load-balancer health checks -- breaks at the same +moment. Plan the change accordingly; there is no partial posture and no gradual cutover. + +=== Turning it off, when you must + +Running operations endpoints in the clear behind a trusted network boundary is a legitimate +deployment, so the downgrade is *possible*. It is not *silent*: a downgraded boot emits +`ApiSheriff-115` at `WARN`, naming the port and what it breaks. The gateway does not refuse to start +-- refusing would push operators toward a worse workaround (see the trap below). + +There are *two doors, and they open the same lever.* Both land on the same runtime key, so you use +whichever fits how the deployment is configured -- never both, and never in expectation of one +overriding the other. + +[cols="1,2,2", options="header"] +|=== +| Door | How | Use it when + +| Neutral (`gateway.yaml`) +| `management: { tls: { enabled: false } }` +| The posture belongs to the configuration document you ship and review -- it is a property of this + gateway's role, not of one environment. + +| Deployment (environment) +| `QUARKUS_MANAGEMENT_TLS_CONFIGURATION_NAME=plain-management` +| The posture differs per environment and you would rather not fork `gateway.yaml` to express it. +|=== + +`plain-management` is a *named TLS bucket that carries no key material*, declared unconditionally in +the shipped configuration. Selecting it *is* the disable: the name resolves, the runtime finds no +key or certificate, and the management listener starts plain. Because it is runtime configuration, +the same image serves both postures with no rebuild -- and a name that does not resolve fails the +boot loudly rather than quietly falling back to something. + +Setting `management.tls.enabled: true`, or omitting the block entirely, *projects nothing at all*. +That silence is deliberate and it is what keeps the environment door working: the two routes +coexist rather than competing. To restore HTTPS, undo whichever door you opened. + +=== Why the escape hatch is a runtime key rather than a bespoke one + +It would have been possible to invent an API Sheriff-specific key that disables management TLS by +blanking the certificate paths. That was rejected. A bespoke mechanism would make this project +responsible for a certificate-blanking trick whose correctness depends on runtime internals, and -- +worse -- it would key the startup warning on *our own declaration* rather than on what the listener +actually resolved. Such a warning reports the declaration, not the reality, and goes quiet the +moment the downgrade arrives by any other route. `ApiSheriff-115` instead inspects the *observed +effective state*, which is why it fires for either door and cannot drift. + +[WARNING] +==== +*`quarkus.management.enabled=false` is NOT the way to get a plain probe endpoint.* It is the +opposite of a fix, for two independent reasons: + +* It is *build-time fixed*, baked into the native image. You cannot change it on a built artifact, + so it is unavailable exactly when you would reach for it. +* It does not remove TLS -- it *moves* health and metrics onto the main, TLS-terminated router. You + end up with an *HTTPS* probe endpoint on the application port, sharing the public listener's + policy and mTLS posture, which is further from a plain probe endpoint than where you started. + +Use the `plain-management` bucket, through either door above. +==== + +=== The port is not in gateway.yaml + +The management *port* is deployment-bound and stays at `quarkus.management.port` (default `9000`), +overridable per deployment with `QUARKUS_MANAGEMENT_PORT`. Writing `management.port` in +`gateway.yaml` is *refused at boot*, with an error at that exact key naming the property that does +own it. The refusal is deliberate: a port declared in `gateway.yaml` would outrank the deployment's +own environment variable, which is precisely the surprise the configuration model exists to prevent. +See link:environment-variable-overrides.adoc[Environment-variable overrides] for the full split. diff --git a/integration-tests/docker-compose.yml b/integration-tests/docker-compose.yml index 754de2a3..dd68ad49 100644 --- a/integration-tests/docker-compose.yml +++ b/integration-tests/docker-compose.yml @@ -213,7 +213,8 @@ services: # insecure-requests — so supplying these two keys converts port 9000 itself to HTTPS. Every # consumer of 9000 (host readiness probes, Prometheus, the k6 health benchmarks) must speak # TLS with -k / insecure_skip_verify, because the certificate is the self-signed localhost - # bundle. + # bundle. Five of the six gateway instances carry this pair; api-sheriff-plain-mgmt is the one + # deliberate exception, documented at its own service definition. - QUARKUS_MANAGEMENT_SSL_CERTIFICATE_FILES=/app/certificates/localhost.crt - QUARKUS_MANAGEMENT_SSL_CERTIFICATE_KEY_FILES=/app/certificates/localhost.key - QUARKUS_TLS_DEFAULT_TRUST__STORE_P12_PATH=/app/certificates/localhost-truststore.p12 @@ -281,9 +282,7 @@ services: # Health check: no in-container healthcheck (the distroless image ships neither a shell nor # curl). Readiness is gated host-side by start-integration-container.sh probing the published - # management port 19000 over HTTPS with -k. The in-image src/main/docker/health-check.sh probes - # the application port 8443 via /dev/tcp and never touches 9000, so it is unaffected by the - # management-TLS switch. + # management port 19000 over HTTPS with -k. # Network isolation (production pattern). The alias is what lets a passthrough client reach # this gateway under a tls.passthrough_sni name: the benchmark's ClientHello carries @@ -330,7 +329,9 @@ services: # Management-interface certificate, kept in lockstep with the primary api-sheriff service — # see that service's comment for the single-port mechanism. Without it this instance's # management port 19001 stays plain HTTP while the primary's is HTTPS, and the host-side - # readiness wait in start-integration-container.sh (now https + -k for both) would hang. + # readiness wait in start-integration-container.sh (https + -k for 19000-19004) would hang. + # api-sheriff-plain-mgmt on 19005 is the one deliberate exception to that lockstep and is + # gated by its own http:// block. - QUARKUS_MANAGEMENT_SSL_CERTIFICATE_FILES=/app/certificates/localhost.crt - QUARKUS_MANAGEMENT_SSL_CERTIFICATE_KEY_FILES=/app/certificates/localhost.key - QUARKUS_TLS_DEFAULT_TRUST__STORE_P12_PATH=/app/certificates/localhost-truststore.p12 @@ -416,7 +417,8 @@ services: # Management-interface certificate, kept in lockstep with the primary api-sheriff service — # see that service's comment for the single-port mechanism. Without it this instance's # management port 19002 stays plain HTTP while the primary's is HTTPS, and the host-side - # readiness wait in start-integration-container.sh (https + -k for all) would hang. + # readiness wait in start-integration-container.sh (https + -k for 19000-19004) would hang. + # api-sheriff-plain-mgmt on 19005 is the one deliberate exception to that lockstep. - QUARKUS_MANAGEMENT_SSL_CERTIFICATE_FILES=/app/certificates/localhost.crt - QUARKUS_MANAGEMENT_SSL_CERTIFICATE_KEY_FILES=/app/certificates/localhost.key - QUARKUS_TLS_DEFAULT_TRUST__STORE_P12_PATH=/app/certificates/localhost-truststore.p12 @@ -504,7 +506,8 @@ services: # Management-interface certificate, kept in lockstep with every other instance — see the # primary api-sheriff service's comment for the single-port mechanism. Without it this # instance's management port 19003 stays plain HTTP while the others are HTTPS, and the - # host-side readiness wait in start-integration-container.sh (https + -k for all) would hang. + # host-side readiness wait in start-integration-container.sh (https + -k for 19000-19004) + # would hang. api-sheriff-plain-mgmt on 19005 is the one deliberate exception. - QUARKUS_MANAGEMENT_SSL_CERTIFICATE_FILES=/app/certificates/localhost.crt - QUARKUS_MANAGEMENT_SSL_CERTIFICATE_KEY_FILES=/app/certificates/localhost.key - QUARKUS_TLS_DEFAULT_TRUST__STORE_P12_PATH=/app/certificates/localhost-truststore.p12 @@ -580,7 +583,8 @@ services: # Management-interface certificate, kept in lockstep with every other instance — see the # primary api-sheriff service's comment for the single-port mechanism. Without it this # instance's management port 19004 stays plain HTTP while the others are HTTPS, and the - # host-side readiness wait in start-integration-container.sh (https + -k for all) would hang. + # host-side readiness wait in start-integration-container.sh (https + -k for 19000-19004) + # would hang. api-sheriff-plain-mgmt on 19005 is the one deliberate exception. - QUARKUS_MANAGEMENT_SSL_CERTIFICATE_FILES=/app/certificates/localhost.crt - QUARKUS_MANAGEMENT_SSL_CERTIFICATE_KEY_FILES=/app/certificates/localhost.key - QUARKUS_TLS_DEFAULT_TRUST__STORE_P12_PATH=/app/certificates/localhost-truststore.p12 @@ -627,6 +631,99 @@ services: - api-sheriff restart: unless-stopped + # The deliberate plain-HTTP-management exception (ADR-0025). + # + # Every other gateway instance serves its management port over HTTPS, and that lockstep is what the + # host-side readiness loop relies on. This one instance proves the supported downgrade path, and it + # is the ONLY instance allowed to break the lockstep — hence its own readiness gate, its own port, + # and this comment. + # + # The downgrade is Quarkus-native, not bespoke: QUARKUS_MANAGEMENT_TLS_CONFIGURATION_NAME selects + # the `plain-management` TLS bucket that application.properties declares with NO key material. The + # named lookup resolves, the key material is null-guarded, and the recorder swaps in the plain + # options because getKeyCertOptions() is null. Because it is runtime configuration, the SAME native + # image every other instance runs is reused unchanged — no rebuild, no variant image, and no config + # overlay: this service mounts the shared sheriff-config directory exactly as shipped. + # + # QUARKUS_MANAGEMENT_ENABLED=false would NOT be an equivalent shortcut: it is fixed at build time + # (baked into the native image, so it could not be set here at all) and it moves health and metrics + # onto the MAIN TLS-terminated router, producing an HTTPS probe endpoint rather than a plain one. + api-sheriff-plain-mgmt: + image: "api-sheriff:distroless" + # Same JVM-default-truststore trust as every other instance — see the primary api-sheriff + # service's command comment for the mechanism (token-sheriff-client#597). + command: + - -Djavax.net.ssl.trustStore=/app/certificates/localhost-truststore.p12 + - -Djavax.net.ssl.trustStorePassword=localhost-trust + - -Djavax.net.ssl.trustStoreType=PKCS12 + ports: + - "10448:8443" # External test port for the plain-management opt-out (test.plain.mgmt.port) + - "19005:9000" # Management interface — PLAIN HTTP on this instance only, deliberately + environment: + - QUARKUS_PROFILE=it + - LOG_FILE_PATH=/logs/quarkus-plain-mgmt.log + - QUARKUS_HTTP_SSL_CERTIFICATE_FILES=/app/certificates/localhost.crt + - QUARKUS_HTTP_SSL_CERTIFICATE_KEY_FILES=/app/certificates/localhost.key + # This instance mounts the SHARED sheriff-config, whose gateway.yaml declares a non-empty + # passthrough_sni — so the SNI front listener claims the public port 8443 exactly as on the + # primary api-sheriff service, and the terminated HTTPS listener MUST be moved to the internal + # port or the two collide and boot fails with "Port 8443 seems to be in use by another + # process". The four other gateway instances escape this only because each overlays + # gateway.yaml with a variant declaring no passthrough_sni; this one deliberately does not + # overlay, so that the management opt-out is the single variable under test. Keep in lockstep + # with the primary service's identical setting — the invariant is documented at the + # passthrough_sni block in sheriff-config/gateway.yaml. + - QUARKUS_HTTP_SSL_PORT=8444 + # THE OPT-OUT. Selecting the key-less `plain-management` bucket takes the management interface + # to plain HTTP; boot still succeeds and emits WARN ApiSheriff-115 naming the port. + - QUARKUS_MANAGEMENT_TLS_CONFIGURATION_NAME=plain-management + # The QUARKUS_MANAGEMENT_SSL_CERTIFICATE_FILES / _KEY_FILES pair every other instance sets is + # deliberately ABSENT here. Its absence is not what produces the downgrade — the selected + # key-less bucket is — but leaving it set would only be misleading noise. + - QUARKUS_TLS_DEFAULT_TRUST__STORE_P12_PATH=/app/certificates/localhost-truststore.p12 + - QUARKUS_TLS_DEFAULT_TRUST__STORE_P12_PASSWORD=localhost-trust + - SHERIFF_CONFIG_DIR=/app/sheriff-config + # Container-side value the bare ${OIDC_CLIENT_SECRET} reference in the shared gateway.yaml + # (oidc.client_secret) resolves to via EnvSecretResolver. + - OIDC_CLIENT_SECRET=integration-secret + depends_on: + keycloak: + condition: service_started + go-httpbin: + condition: service_started + asset-origin: + condition: service_started + grpc-echo: + condition: service_healthy + volumes: + - ./src/main/docker/certificates:/app/certificates:ro + # Shared config dir, unmodified: the opt-out is an environment variable, so this instance needs + # no gateway.yaml overlay at all. + - ./src/main/docker/sheriff-config:/app/sheriff-config:ro + - ./src/main/docker/assets:/app/assets:ro + - ${LOG_TARGET_DIR:-./target/quarkus-logs}:/logs:rw + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + read_only: true + tmpfs: + - /tmp:rw,noexec,nosuid,size=100m + deploy: + resources: + limits: + memory: 512M + cpus: '4.0' + reservations: + memory: 256M + cpus: '1.0' + # No in-container healthcheck: the distroless image ships neither a shell nor curl. Readiness is + # gated host-side by start-integration-container.sh — in its OWN http:// block, never in the + # https-hard-coded loop that gates 19000-19004. + networks: + - api-sheriff + restart: unless-stopped + prometheus: image: prom/prometheus:v3.6.0 ports: diff --git a/integration-tests/pom.xml b/integration-tests/pom.xml index 883906a4..8f76bb7d 100644 --- a/integration-tests/pom.xml +++ b/integration-tests/pom.xml @@ -262,6 +262,17 @@ upgrades can never exhaust anything and the test would pass whether or not the permits are returned. --> 10447 + + 19005 + ${project.build.directory}/quarkus-logs org.jboss.logmanager.LogManager ${skipITs} diff --git a/integration-tests/scripts/start-integration-container.sh b/integration-tests/scripts/start-integration-container.sh index a476f9bd..4072825f 100755 --- a/integration-tests/scripts/start-integration-container.sh +++ b/integration-tests/scripts/start-integration-container.sh @@ -239,6 +239,34 @@ for gateway_instance in "api-sheriff-cookie:19002" "api-sheriff-cookie-2:19003" done done +# Wait for the plain-management instance (api-sheriff-plain-mgmt on 10448 / management 19005). +# +# It gets its OWN block rather than an entry in the loop above, and the reason is structural: the +# loop's probe URL is hard-coded to https://, and this instance's whole purpose is that its +# management port is PLAIN HTTP (it selects the key-less `plain-management` TLS bucket via +# QUARKUS_MANAGEMENT_TLS_CONFIGURATION_NAME). Adding it to the loop would probe https:// against a +# plain listener and hang for the full 30 seconds. Keep the scheme here as http:// — if this probe +# ever needs -k, the opt-out has silently stopped working and that is the bug, not the probe. +echo "⏳ Waiting for the api-sheriff-plain-mgmt gateway instance to be ready..." +for i in {1..30}; do + if curl -sf http://localhost:19005/q/health/live > /dev/null 2>&1; then + echo "✅ api-sheriff-plain-mgmt gateway instance is ready (management on PLAIN HTTP, deliberately)!" + break + fi + if [ $i -eq 30 ]; then + echo "❌ api-sheriff-plain-mgmt gateway instance failed to start within 30 seconds" + DIAG_DIR="target/failsafe-reports" + mkdir -p "$DIAG_DIR" + echo "----- $COMPOSE_BASE logs api-sheriff-plain-mgmt -----" + $COMPOSE_BASE logs --no-color api-sheriff-plain-mgmt 2>&1 | tee "$DIAG_DIR/api-sheriff-plain-mgmt-app.log" + curl -s http://localhost:19005/q/health 2>&1 | tee "$DIAG_DIR/api-sheriff-plain-mgmt-health.json" + echo "" + exit 1 + fi + echo "⏳ Waiting for api-sheriff-plain-mgmt gateway... (attempt $i/30)" + sleep 1 +done + # Extract native startup time from logs NATIVE_STARTUP=$($COMPOSE_BASE logs api-sheriff 2>/dev/null | grep "started in" | sed -n 's/.*started in \([0-9.]*\)s.*/\1/p' | tail -1) if [ ! -z "$NATIVE_STARTUP" ]; then diff --git a/integration-tests/src/main/docker/sheriff-config/gateway.yaml b/integration-tests/src/main/docker/sheriff-config/gateway.yaml index 9358e1c0..a0cc7ad1 100644 --- a/integration-tests/src/main/docker/sheriff-config/gateway.yaml +++ b/integration-tests/src/main/docker/sheriff-config/gateway.yaml @@ -29,6 +29,25 @@ allowed_methods: ["GET", "POST", "PUT", "DELETE"] security_defaults: profile: strict tls: + # TLS POLICY for the terminated listener, single-sourced from this document and bound onto the + # listener by tls/TlsServerCustomizer (ADR-0025). Declared here rather than left implicit so the + # integration stack actually EXERCISES a gateway.yaml-sourced protocol floor and cipher allowlist + # — the keys these replaced were raw quarkus.tls.* properties that never reached the listener, so + # a fixture that declares nothing would prove only that the absence of policy is harmless. + # + # The floor is 1.2 rather than 1.3 deliberately: it enables TLSv1.2 AND TLSv1.3, so the suite's + # mixed clients keep negotiating and the assertion under test is that the DECLARED policy is in + # force, not that the strictest possible policy is survivable. + min_version: "1.2" + # The allowlist spans both enabled protocols on purpose. TLS_AES_* are TLS 1.3 suites; the + # ECDHE_RSA pair covers TLS 1.2 against the RSA server certificate the certificates/ scripts + # generate. Listing only 1.3 suites under a 1.2 floor would leave TLS 1.2 with nothing to + # negotiate — a listener that silently accepts no 1.2 handshake at all. + cipher_suites: + - TLS_AES_256_GCM_SHA384 + - TLS_AES_128_GCM_SHA256 + - TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 + - TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 # ALPN advertises h2 alongside http/1.1 so the http2 benchmark aspect negotiates # HTTP/2 at the edge instead of silently falling back to HTTP/1.1 — a fallback # would report an h2 number that was never measured over h2. diff --git a/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/ManagementPlainHttpActivationWiringTest.java b/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/ManagementPlainHttpActivationWiringTest.java new file mode 100644 index 00000000..fc5db210 --- /dev/null +++ b/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/ManagementPlainHttpActivationWiringTest.java @@ -0,0 +1,178 @@ +/* + * Copyright © 2026 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.gateway.integration; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import org.yaml.snakeyaml.Yaml; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Fast, no-Docker surefire guard that the committed deployment descriptors actually + * activate the plain-HTTP management opt-out on the dedicated + * {@code api-sheriff-plain-mgmt} instance — and, as the matched negative control, that no other + * gateway instance activates it by accident. + *

+ * The guard exists because the opt-out is opt-in and its failure mode is silent in exactly the + * direction that hides it: an instance whose activation variable was dropped simply serves HTTPS + * management like everything else, and {@code ManagementPlainHttpOptOutIT} would then be asserting + * plain HTTP against a listener that is not the one under test. Lesson 2026-07-25-15-001 — an opt-in + * runtime feature needs a deployment-activation assertion, not only a component test. + *

+ * The selection is only half the mechanism. The named bucket must also exist in the shipped + * {@code application.properties} and must carry no key material: an unresolvable name fails + * the boot outright, and a name that resolves to a bucket carrying a certificate would keep + * management on HTTPS while looking configured. Both halves are asserted here, together with the + * standing prohibition on ever declaring a DEFAULT {@code quarkus.tls.key-store.*} bucket, which the + * management interface would silently inherit HTTPS from. + *

+ * It parses the committed descriptors only — it starts no container and reaches no network. + * + * @author API Sheriff Team + * @since 1.0 + */ +@DisplayName("Plain-HTTP management opt-out — deployment activation") +class ManagementPlainHttpActivationWiringTest { + + /** The module base directory (surefire runs with the module root as the working directory). */ + private static final Path MODULE = Path.of(System.getProperty("user.dir")); + private static final Path APPLICATION_PROPERTIES = + MODULE.resolve("../api-sheriff/src/main/resources/application.properties"); + + private static final String OPT_OUT_SERVICE = "api-sheriff-plain-mgmt"; + private static final String BUCKET = "plain-management"; + private static final String SELECTION = "QUARKUS_MANAGEMENT_TLS_CONFIGURATION_NAME=" + BUCKET; + private static final List HTTPS_SERVICES = List.of( + "api-sheriff", "api-sheriff-mtls", "api-sheriff-cookie", "api-sheriff-cookie-2", + "api-sheriff-ws-admission"); + + @Test + @DisplayName("the opt-out instance selects the key-less TLS bucket") + void optOutInstanceSelectsTheKeyLessBucket() throws Exception { + List environment = environment(OPT_OUT_SERVICE); + + assertTrue(environment.contains(SELECTION), + () -> OPT_OUT_SERVICE + " must set " + SELECTION + " — without it the instance serves " + + "HTTPS management like every other one and the opt-out IT tests nothing"); + } + + @Test + @DisplayName("the opt-out instance carries no management certificate") + void optOutInstanceCarriesNoManagementCertificate() throws Exception { + List environment = environment(OPT_OUT_SERVICE); + + assertFalse(environment.stream().anyMatch(e -> e.startsWith("QUARKUS_MANAGEMENT_SSL_CERTIFICATE_")), + "the opt-out instance must not also configure a management certificate — the selected " + + "key-less bucket already wins, so leaving one set would be misleading noise"); + } + + @Test + @DisplayName("the opt-out instance publishes management port 19005") + void optOutInstancePublishesTheDedicatedManagementPort() throws Exception { + assertTrue(publishedPorts(OPT_OUT_SERVICE).stream().anyMatch(p -> p.startsWith("19005:")), + "the opt-out instance must publish 19005 — it is the port the dedicated http:// " + + "readiness gate and ManagementPlainHttpOptOutIT both address"); + } + + @Test + @DisplayName("no other gateway instance activates the opt-out") + void noOtherInstanceActivatesTheOptOut() throws Exception { + for (String service : HTTPS_SERVICES) { + List environment = environment(service); + assertFalse(environment.stream().anyMatch(e -> e.startsWith("QUARKUS_MANAGEMENT_TLS_CONFIGURATION_NAME=")), + () -> service + " must NOT select a management TLS configuration name — the " + + "https + -k readiness gate and the k6 health benchmarks depend on its " + + "management port staying HTTPS"); + } + } + + @Test + @DisplayName("the selected TLS bucket is declared in the shipped application.properties, unconditionally") + void theSelectedBucketIsDeclaredUnconditionally() throws Exception { + List bucketKeys = shippedPropertyLines().stream() + .filter(line -> line.startsWith("quarkus.tls." + BUCKET + ".")) + .toList(); + + assertFalse(bucketKeys.isEmpty(), + "the '" + BUCKET + "' bucket must be declared in application.properties — an " + + "unresolvable configuration name fails the boot with a ConfigurationException"); + assertTrue(shippedPropertyLines().stream().noneMatch(line -> line.contains("%") + && line.contains("quarkus.tls." + BUCKET + ".")), + "the bucket declaration must not be profile-scoped: only the SELECTION is per-deployment"); + } + + @Test + @DisplayName("the selected TLS bucket carries no key material, and no default bucket exists") + void noKeyMaterialReachesTheTlsRegistry() throws Exception { + List properties = shippedPropertyLines(); + + assertTrue(properties.stream().noneMatch(line -> line.startsWith("quarkus.tls." + BUCKET + ".key-store.") + || line.startsWith("quarkus.tls." + BUCKET + ".trust-store.")), + "the '" + BUCKET + "' bucket must carry no key or trust material — its emptiness IS " + + "the mechanism that leaves getKeyCertOptions() null"); + assertTrue(properties.stream().noneMatch(line -> line.startsWith("quarkus.tls.key-store.")), + "no DEFAULT quarkus.tls.key-store.* bucket may be declared: the management interface " + + "falls back to the default registry bucket and would inherit HTTPS through a " + + "path no configuration file mentions (upstream quarkus issue 43380)"); + } + + private static List shippedPropertyLines() throws IOException { + return Files.readAllLines(APPLICATION_PROPERTIES).stream() + .map(String::strip) + .filter(line -> !line.isEmpty() && !line.startsWith("#")) + .toList(); + } + + @SuppressWarnings("unchecked") + private static List environment(String service) throws IOException { + Map serviceMap = (Map) service(service); + Object environment = serviceMap.get("environment"); + assertInstanceOf(List.class, environment, "the '" + service + "' service environment must be a list"); + return ((List) environment).stream().map(String::valueOf).toList(); + } + + @SuppressWarnings("unchecked") + private static List publishedPorts(String service) throws IOException { + Map serviceMap = (Map) service(service); + Object ports = serviceMap.get("ports"); + assertInstanceOf(List.class, ports, "the '" + service + "' service must publish ports"); + return ((List) ports).stream().map(String::valueOf).toList(); + } + + @SuppressWarnings("unchecked") + private static Object service(String service) throws IOException { + Map compose; + try (InputStream in = Files.newInputStream(MODULE.resolve("docker-compose.yml"))) { + compose = new Yaml().loadAs(in, Map.class); + } + Object services = compose.get("services"); + assertInstanceOf(Map.class, services, "docker-compose.yml must declare services"); + Object node = ((Map) services).get(service); + assertNotNull(node, "docker-compose.yml must declare the '" + service + "' service"); + return node; + } +} diff --git a/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/ManagementPlainHttpOptOutIT.java b/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/ManagementPlainHttpOptOutIT.java new file mode 100644 index 00000000..49a75230 --- /dev/null +++ b/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/ManagementPlainHttpOptOutIT.java @@ -0,0 +1,118 @@ +/* + * Copyright © 2026 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.gateway.integration; + +import static io.restassured.RestAssured.given; +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.io.IOException; +import java.io.UncheckedIOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Proves the supported plain-HTTP management downgrade end to end against the dedicated + * {@code api-sheriff-plain-mgmt} compose instance (management published on 19005). + *

+ * The downgrade is Quarkus-native: the instance sets + * {@code QUARKUS_MANAGEMENT_TLS_CONFIGURATION_NAME=plain-management}, selecting the TLS bucket that + * {@code application.properties} declares with no key material, so the recorder finds no key/cert and + * starts the management listener plain. Nothing is rebuilt for it — the instance runs the same native + * image as every other gateway service. + *

+ * Three properties are asserted together because each alone is satisfiable by an accident: that the + * port answers over plain HTTP, that it does not answer over HTTPS (so the assertion above is + * not merely a redirect or a tolerant client), and that boot said so out loud with WARN + * {@code ApiSheriff-115}. A silent downgrade would satisfy the first two and is exactly the outcome + * the audit exists to prevent. + * + * @author API Sheriff Team + * @since 1.0 + */ +@DisplayName("Plain-HTTP management opt-out") +class ManagementPlainHttpOptOutIT { + + private static final String DEFAULT_PLAIN_MANAGEMENT_PORT = "19005"; + private static final String LOG_FILE = "quarkus-plain-mgmt.log"; + + private static String plainManagementBaseUri() { + return "http://localhost:" + + System.getProperty("test.plain.mgmt.management.port", DEFAULT_PLAIN_MANAGEMENT_PORT); + } + + @Test + @DisplayName("the management port serves health over PLAIN HTTP") + void managementServesHealthOverPlainHttp() { + var response = given() + .baseUri(plainManagementBaseUri()) + .when() + .get("/q/health/live") + .then() + .statusCode(200) + .extract(); + + assertEquals("UP", response.path("status"), + "the downgraded management interface must still serve a working health endpoint"); + } + + @Test + @DisplayName("the same management port refuses HTTPS — the downgrade is real, not a tolerant client") + void managementRefusesHttps() { + HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build(); + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(plainManagementBaseUri().replace("http://", "https://") + "/q/health/live")) + .timeout(Duration.ofSeconds(5)) + .GET() + .build(); + + assertThrows(IOException.class, () -> client.send(request, HttpResponse.BodyHandlers.discarding()), + "a plain listener cannot complete a TLS handshake; if this succeeds the instance is " + + "serving HTTPS and the opt-out silently did nothing"); + } + + @Test + @DisplayName("boot announced the downgrade with WARN ApiSheriff-115") + void bootWarnedAboutTheDowngrade() { + String log = readContainerLog(); + + assertTrue(log.contains("ApiSheriff-115"), + "the downgrade must be announced with the WARN identifier so it is greppable"); + assertTrue(log.contains("Management interface is serving PLAIN HTTP on port"), + "the WARN must name what actually happened, not merely fire"); + } + + private static String readContainerLog() { + Path logDir = Path.of(System.getProperty("test.log.dir", "target/quarkus-logs")); + Path logFile = logDir.resolve(LOG_FILE); + assertTrue(Files.isRegularFile(logFile), + () -> "expected the plain-management instance log at " + logFile.toAbsolutePath()); + try { + return Files.readString(logFile); + } catch (IOException e) { + throw new UncheckedIOException("cannot read " + logFile, e); + } + } +}