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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 44 additions & 19 deletions api-sheriff/src/main/resources/application.properties
Original file line number Diff line number Diff line change
Expand Up @@ -156,30 +156,45 @@ sheriff.config.dir=config
# TokenValidator used by GatewayEdgeRoute -> AuthenticationStage and reported on by
# GatewayReadinessCheck. No property in this file participates in it.
# - The token-sheriff-validation-quarkus extension carries its OWN sheriff.token.issuers.<name>.*
# Quarkus namespace, which feeds only its parallel UNQUALIFIED beans — the two health probes the
# exclusion below removes. The @GatewayValidator qualifier exists precisely to bypass them.
# Quarkus namespace, which feeds only its parallel UNQUALIFIED beans — the two health probes and
# the metrics collector the exclusion below removes. The @GatewayValidator qualifier exists
# precisely to bypass them.
#
# The shipped artifact configures no issuer on that extension surface and no named trust profile,
# in ANY profile. That is the contract, not an omission: a deployment that names a
# `jwks.tls_profile` in gateway.yaml supplies the matching trust bucket itself (JwksTrustProfileResolver
# refuses a name it cannot resolve rather than falling back to default trust), and a deployment that
# wants extension-backed health supplies its own issuer.

# Bean-level exclusion of the token-sheriff-validation-quarkus extension's two health probes:
# health.JwksEndpointHealthCheck and health.TokenValidatorHealthCheck.
# Bean-level exclusion of the token-sheriff-validation-quarkus extension's unqualified beans:
# health.JwksEndpointHealthCheck, health.TokenValidatorHealthCheck and metrics.JwtMetricsCollector.
#
# Both probes report on the extension's own UNQUALIFIED TokenValidator — the one it builds from its
# All three reach the extension's own UNQUALIFIED TokenValidator — the one it builds from its
# sheriff.token.issuers.<name>.* Quarkus namespace. This gateway never uses that validator. The
# request path injects the @GatewayValidator-qualified TokenValidator built from gateway.yaml's
# token_validation block (GatewayEdgeRoute:272 -> AuthenticationStage), and no production class here
# imports de.cuioss.sheriff.token.quarkus.* at all. The extension's namespace is consequently empty
# in every profile, so both probes report DOWN with "No issuer configurations found in properties"
# and drag aggregate /q/health/ready down — a false negative about machinery the product does not use.
#
# No JWKS observability is lost by excluding them, because they were observing nothing: both iterate
# the EXTENSION's issuer list, which is empty here, so neither ever reached this gateway's JWKS
# loaders. GatewayReadinessCheck reports `jwks` for the real, @GatewayValidator-qualified validator
# built from gateway.yaml, in that same readiness payload.
# in every profile, so its IssuerConfigResolver raises "No issuer configurations found in properties"
# for every one of them.
#
# One mismatch, two symptoms, because the three beans are driven differently:
# - The two health probes are driven by /q/health/ready and report DOWN. SmallRye composes
# readiness as a conjunction, so they drag the AGGREGATE down — a false negative about machinery
# the product does not use, which leaves a correctly-configured gateway permanently not ready.
# - metrics.JwtMetricsCollector constructor-injects the SecurityEventCounter that same unqualified
# producer produces, and carries @Scheduled(every = "10s"). Bean creation therefore fails on
# every tick, and the scheduler logs roughly sixty lines of stack trace each time. Readiness is
# unaffected and token validation keeps working; what is lost is the log. That noise buries every
# other line an operator needs, which is a real cost during integration work.
#
# No observability is lost by excluding them, because they were observing nothing. Both probes
# iterate the EXTENSION's issuer list, which is empty here, so neither ever reached this gateway's
# JWKS loaders; GatewayReadinessCheck reports `jwks` for the real, @GatewayValidator-qualified
# validator built from gateway.yaml, in that same readiness payload. The metrics collector likewise
# never published a single meter — it failed in its constructor's dependency on every tick — so
# excluding it removes stack traces, not JWT metrics. Publishing the QUALIFIED validator's
# SecurityEventCounter as meters is a real gap and separate work; it belongs next to
# SheriffMetrics.bindSecurityEventCounter, which already does exactly this for the cui-http counter.
#
# Be precise about what that datum means, though, because it is narrower than it reads. It is a
# BOOT-TIME constructibility fact, not a live JWKS signal: TokenValidatorProducer.onStartup forces the
Expand All @@ -191,16 +206,26 @@ sheriff.config.dir=config
# issuer and would have carried it, had it ever been pointed at these issuers. Closing the gap means
# giving GatewayReadinessCheck a live loader-status read; it is not something this exclusion removed.
#
# The scope is deliberately those two beans in that ONE package and nothing else. This is NOT
# quarkus.health.extensions.enabled=false and NOT any other global switch: blanket-disabling a
# security-relevant readiness check to force a probe green is forbidden (ADR-0022 posture). The
# extension DEPENDENCY likewise stays — its deployment processor registers the GraalVM reflection for
# the de.cuioss.sheriff.token.validation.* classes this gateway does use at runtime, so dropping it
# would break the native image.
# The scope is deliberately those beans in those TWO packages and nothing else. This is NOT
# quarkus.health.extensions.enabled=false, NOT quarkus.scheduler.enabled=false and NOT any other
# global switch: blanket-disabling a security-relevant readiness check to force a probe green is
# forbidden (ADR-0022 posture), and disabling the scheduler wholesale would silently take out any
# future scheduled job this gateway itself wants. The extension DEPENDENCY likewise stays — its
# deployment processor registers the GraalVM reflection for the de.cuioss.sheriff.token.validation.*
# classes this gateway does use at runtime, so dropping it would break the native image.
#
# Named per package rather than as one de.cuioss.sheriff.token.quarkus.* sweep, because that surface
# also carries beans a future change here may legitimately want (the @BearerToken producer, the
# claim-mapper registry). A sweep would remove them silently at the moment someone reached for one.
#
# ExtensionUnqualifiedBeanExclusionTest pins both packages, and its second assertion is the fitness
# function for the next bean of this shape: no scheduled job anywhere may name the extension's
# Quarkus wiring. That catches an eagerly-driven newcomer the moment it lands, which a bean-by-bean
# assertion never would.
#
# Declared UNCONDITIONALLY and in no profile: a %-profile-scoped exclusion would itself be a profile
# branch in the shipped configuration surface, which the standing rule forbids.
quarkus.arc.exclude-types=de.cuioss.sheriff.token.quarkus.health.*
quarkus.arc.exclude-types=de.cuioss.sheriff.token.quarkus.health.*,de.cuioss.sheriff.token.quarkus.metrics.*

# Logging
quarkus.log.level=INFO
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
/*
* 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.quarkus;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;

import java.util.List;


import de.cuioss.sheriff.token.quarkus.metrics.JwtMetricsCollector;

import io.quarkus.scheduler.Scheduler;
import io.quarkus.scheduler.Trigger;
import io.quarkus.test.junit.QuarkusTest;
import jakarta.enterprise.inject.Instance;
import jakarta.inject.Inject;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

/**
* Pins the <em>scope</em> of the token-validation extension's bean exclusion: no extension bean that
* reaches the extension's <em>unqualified</em> {@code TokenValidator} survives into the running
* application.
* <p>
* {@link DefaultProfileReadinessTest} pins the readiness half of the same decision — the two
* {@code health.*} probes. This class pins the other half, and it exists because the health probes
* were not the only bean in that position. The extension's {@code metrics.JwtMetricsCollector}
* constructor-injects a {@code SecurityEventCounter} produced by the same unqualified
* {@code producer.TokenValidatorProducer}, which resolves issuers from the extension's
* {@code sheriff.token.issuers.<name>.*} namespace. This gateway never populates that namespace — the
* request path runs off the {@code @GatewayValidator}-qualified validator built from
* {@code gateway.yaml} — so the producer throws
* {@code IllegalStateException: No issuer configurations found in properties} on every attempt to
* create it.
* <p>
* That bean is {@code @Scheduled(every = "10s")}, which turns a latent misfit into a continuous one:
* the collector is re-created on every tick, fails on every tick, and the scheduler logs a full stack
* trace each time. The gateway itself stays healthy and token validation keeps working — the damage
* is roughly sixty lines of stack trace every ten seconds, which buries every other log line the
* operator needs.
* <p>
* <strong>Two assertions, because either alone would be weak.</strong>
* <ul>
* <li>The <em>specific</em> one — {@code JwtMetricsCollector} is not a bean — pins the exclusion
* entry that fixes the reported defect. Paired with {@link SheriffMetrics} being resolvable, so a run
* in which CDI contributed nothing at all cannot masquerade as a successful exclusion.</li>
* <li>The <em>general</em> one — no scheduled job anywhere names the extension's Quarkus package —
* is the fitness function. The exclusion is a type pattern over another project's packages, and the
* failure mode it guards is a <em>future</em> extension bean landing in the same position: eagerly
* driven, reaching the unqualified producer, failing on a schedule. A bean-by-bean assertion would
* only ever catch the beans someone already knew about.</li>
* </ul>
* <p>
* Note what the general assertion does <em>not</em> claim. It observes registered scheduled jobs, so
* it catches an eagerly-driven extension bean only when the scheduler is what drives it. An extension
* bean driven by a startup observer or by an HTTP endpoint would pass it. The exclusion remains a
* deliberate, reviewed decision rather than something these assertions can derive.
* <p>
* With the exclusion in place nothing in this application schedules anything, so Quarkus does not
* start the scheduler at all and the assertion passes over an empty set. That is the intended resting
* state and it is not vacuous for the purpose: the moment a bean under the extension's Quarkus
* package registers a {@code @Scheduled} method the scheduler starts, the job appears, and the
* assertion fires — which is exactly the regression it exists to catch. Because that resting state
* makes the assertion green over an empty input, the filter it depends on carries its own matched
* positive/negative control, so a filter that had stopped matching anything could not pass for a
* gateway that schedules nothing.
*
* @author API Sheriff Team
* @since 1.0
*/
@QuarkusTest
@DisplayName("Token-validation extension: unqualified bean exclusion")
class ExtensionUnqualifiedBeanExclusionTest {

/**
* Package prefix of the extension's Quarkus wiring — the whole surface that reads the
* {@code sheriff.token.issuers.*} namespace this gateway never populates. Matched as a substring
* because a scheduled job is identified by {@code <bean class>#<method>} with a generated prefix.
*/
private static final String EXTENSION_QUARKUS_PACKAGE = "de.cuioss.sheriff.token.quarkus.";

@Inject
Instance<JwtMetricsCollector> extensionMetricsCollector;

@Inject
Instance<SheriffMetrics> gatewayMetrics;

@Inject
Instance<Scheduler> scheduler;

@Test
@DisplayName("Should exclude the extension's JwtMetricsCollector from bean discovery")
void shouldExcludeExtensionMetricsCollectorFromBeanDiscovery() {
// Arrange — the paired guard: the gateway's own metrics bean proves CDI and the metrics
// subsystem genuinely ran, so the absence asserted below is an exclusion rather than a
// container that contributed nothing.
assertTrue(gatewayMetrics.isResolvable(),
"guard: the gateway's own " + SheriffMetrics.class.getSimpleName() + " must resolve — "
+ "without it the absence asserted below would prove nothing about the exclusion");

// Act + Assert — the exclusion took effect: the extension's collector is not a bean at all.
assertFalse(extensionMetricsCollector.isResolvable(),
"quarkus.arc.exclude-types must remove " + JwtMetricsCollector.class.getName()
+ " from bean discovery. It constructor-injects the SecurityEventCounter produced by the "
+ "extension's UNQUALIFIED TokenValidatorProducer, which resolves issuers from the "
+ "sheriff.token.issuers.* namespace this gateway never populates — so every @Scheduled "
+ "tick fails with 'No issuer configurations found in properties'");
}

@Test
@DisplayName("Should register no scheduled job from the token-validation extension")
void shouldRegisterNoScheduledJobFromTheExtension() {
// Arrange — without a resolvable Scheduler there is nothing to inspect and the assertion
// below would be vacuously green.
assertTrue(scheduler.isResolvable(),
"guard: the Quarkus Scheduler must resolve — otherwise the registered-job set is "
+ "unobservable and the assertion below proves nothing");

// Act — a not-started scheduler is the expected state here and is STRONGER than an empty
// filtered set: Quarkus starts the scheduler only once some bean registers a @Scheduled
// method, so "not started" means nothing is scheduled anywhere in the application, extension
// or otherwise. Reading getScheduledJobs() in that state throws rather than returning empty.
Scheduler resolved = scheduler.get();
List<String> extensionJobs = resolved.isStarted()
? extensionJobsAmong(resolved.getScheduledJobs().stream()
.map(ExtensionUnqualifiedBeanExclusionTest::describe)
.toList())
: List.of();

// Assert — nothing from the extension's Quarkus wiring runs on a schedule in this gateway.
assertTrue(extensionJobs.isEmpty(),
"no scheduled job may come from " + EXTENSION_QUARKUS_PACKAGE + "* — every bean there reads "
+ "the extension's empty sheriff.token.issuers.* namespace, so a scheduled one fails on "
+ "every tick and floods the log. Registered extension jobs: " + extensionJobs
+ ". Extend quarkus.arc.exclude-types in application.properties rather than relaxing "
+ "this assertion");
}

/**
* Matched positive/negative control over the filter the assertion above depends on. Without it
* that assertion is unfalsifiable-by-inspection: it is green today because nothing is scheduled,
* and it would be equally green if the filter matched nothing at all — which is precisely the
* state it must detect when a future extension bean lands.
*/
@Test
@DisplayName("The filter flags an extension-owned job and ignores a gateway-owned one")
void filterFlagsExtensionJobsAndIgnoresGatewayJobs() {
// Arrange — the extension-owned entry is the real trigger identity observed on a gateway
// running without the exclusion; the gateway-owned one is the near miss it must not flag.
String extensionJob = "1_de.cuioss.sheriff.token.quarkus.metrics.JwtMetricsCollector#updateCounters "
+ "(de.cuioss.sheriff.token.quarkus.metrics.JwtMetricsCollector#updateCounters)";
String gatewayJob = "2_de.cuioss.sheriff.gateway.quarkus.SheriffMetrics#sweep "
+ "(de.cuioss.sheriff.gateway.quarkus.SheriffMetrics#sweep)";
String validationLibraryJob = "3_de.cuioss.sheriff.token.validation.SomeJob#run "
+ "(de.cuioss.sheriff.token.validation.SomeJob#run)";

// Act
List<String> flagged = extensionJobsAmong(List.of(gatewayJob, extensionJob, validationLibraryJob));

// Assert — the filter selects the extension's Quarkus wiring and only that. The validation
// library entry is the near miss that matters: it shares the de.cuioss.sheriff.token prefix
// but is the library this gateway genuinely uses, so a prefix trimmed one segment too short
// would over-select it.
assertEquals(List.of(extensionJob), flagged,
"the filter must flag a job under " + EXTENSION_QUARKUS_PACKAGE + " and leave both the "
+ "gateway's own package and the validation library the gateway does use alone");
}

/** @return the entries of {@code jobDescriptions} owned by the extension's Quarkus wiring */
private static List<String> extensionJobsAmong(List<String> jobDescriptions) {
return jobDescriptions.stream()
.filter(description -> description.contains(EXTENSION_QUARKUS_PACKAGE))
.toList();
}

/** @return an identity for {@code trigger} that names the bean class whichever field carries it */
private static String describe(Trigger trigger) {
return trigger.getId() + " (" + trigger.getMethodDescription() + ")";
}
}
Loading
Loading