From ae74775c83d87c43a724651d01ef2a3eaead8362 Mon Sep 17 00:00:00 2001
From: Esteban Gaviria Zambrano
Date: Mon, 31 Aug 2026 12:03:33 -0500
Subject: [PATCH 1/2] test(us3): specify malformed JWT rejection
---
.../usersapi/UsersApiApplicationTests.java | 17 ++++++++++++++---
1 file changed, 14 insertions(+), 3 deletions(-)
diff --git a/src/test/java/com/elgris/usersapi/UsersApiApplicationTests.java b/src/test/java/com/elgris/usersapi/UsersApiApplicationTests.java
index 0259e7e..0ec702a 100644
--- a/src/test/java/com/elgris/usersapi/UsersApiApplicationTests.java
+++ b/src/test/java/com/elgris/usersapi/UsersApiApplicationTests.java
@@ -137,6 +137,13 @@ void jwtSubjectCannotReadAnotherUser() throws Exception {
.andExpect(status().isForbidden());
}
+ @Test
+ void malformedSignedJwtIsRejectedInsteadOfEscapingAsServerError() throws Exception {
+ mockMvc.perform(get("/users/admin")
+ .header("Authorization", "Bearer " + signedTokenWithPayload("not-json")))
+ .andExpect(status().isUnauthorized());
+ }
+
@Test
void serviceOwnedAndHttpMetricsAreExported() throws Exception {
mockMvc.perform(get("/count")
@@ -150,12 +157,16 @@ void serviceOwnedAndHttpMetricsAreExported() throws Exception {
}
private String tokenFor(String username) throws Exception {
- Base64.Encoder encoder = Base64.getUrlEncoder().withoutPadding();
- String header = encoder.encodeToString(objectMapper.writeValueAsBytes(Map.of("alg", "HS256", "typ", "JWT")));
- String payload = encoder.encodeToString(objectMapper.writeValueAsBytes(Map.of(
+ return signedTokenWithPayload(objectMapper.writeValueAsString(Map.of(
"username", username,
"scope", "read",
"exp", Instant.now().plusSeconds(300).getEpochSecond())));
+ }
+
+ private String signedTokenWithPayload(String payloadJson) throws Exception {
+ Base64.Encoder encoder = Base64.getUrlEncoder().withoutPadding();
+ String header = encoder.encodeToString(objectMapper.writeValueAsBytes(Map.of("alg", "HS256", "typ", "JWT")));
+ String payload = encoder.encodeToString(payloadJson.getBytes(StandardCharsets.UTF_8));
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec("unit-test-secret".getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
String signature = encoder.encodeToString(mac.doFinal((header + "." + payload).getBytes(StandardCharsets.US_ASCII)));
From 6b404fc8f2e63f4cf18a737c57b6ad2cb3da23be Mon Sep 17 00:00:00 2001
From: Esteban Gaviria Zambrano
Date: Mon, 31 Aug 2026 12:14:41 -0500
Subject: [PATCH 2/2] feat(us3): implement users-api operational contract
---
README.md | 11 ++-
contracts/openapi.yaml | 81 ++++++++++++++-----
pom.xml | 6 +-
.../elgris/usersapi/UsersApiApplication.java | 17 +++-
.../usersapi/api/CounterController.java | 7 +-
.../elgris/usersapi/api/UsersController.java | 8 +-
.../configuration/CorrelationIdFilter.java | 40 +++++++++
.../configuration/OperationalProperties.java | 18 +++++
.../configuration/SecurityConfiguration.java | 2 +
.../security/JwtAuthenticationFilter.java | 16 +++-
src/main/resources/application.properties | 14 +++-
src/main/resources/logback-spring.xml | 3 +
12 files changed, 191 insertions(+), 32 deletions(-)
create mode 100644 src/main/java/com/elgris/usersapi/configuration/CorrelationIdFilter.java
create mode 100644 src/main/java/com/elgris/usersapi/configuration/OperationalProperties.java
diff --git a/README.md b/README.md
index f2c39f3..ecb27b6 100644
--- a/README.md
+++ b/README.md
@@ -7,8 +7,17 @@ This service is written in Java with Spring Boot. It provides simple API to retr
## Configuration
The service scans environment for variables:
+
- `JWT_SECRET` - secret value for JWT token processing. Must be the same amongst all components.
- `SERVER_PORT` - the port the service takes.
+- `OTEL_TRACES_SAMPLER_ARG` - trace sampling probability; defaults to `1.0`.
+- `ZIPKIN_URL` - Zipkin v2 spans endpoint.
+- `USERS_API_FEATURE_VERBOSE_SECURITY_ERRORS` - exposes JWT validation details when `true`; defaults to `false`.
+
+Public operational endpoints are `/health/startup`, `/health/readiness`,
+`/health/liveness`, `/health`, and `/prometheus`. Health responses never expose
+component details. API responses return the accepted or generated
+`X-Request-Id` correlation identifier.
## Building
@@ -30,4 +39,4 @@ where `$token` is the response you get from [Auth API](/auth-api).
Here you can find the software required to run this microservice, as well as the version we have tested.
| Dependency | Version |
|-------------|----------|
-| Java | openJDK8 |
\ No newline at end of file
+| Java | OpenJDK 21 |
diff --git a/contracts/openapi.yaml b/contracts/openapi.yaml
index d0b6f91..ce3bfd9 100644
--- a/contracts/openapi.yaml
+++ b/contracts/openapi.yaml
@@ -4,8 +4,9 @@ info:
version: 1.0.0
description: >
JWT-protected user directory backed by an in-process datastore. A caller may
- read only the user named in its own token. Contract-first source of truth
- (spec 007 / T009).
+ read only the user named in its own token. Requests accept and responses
+ return X-Request-Id for correlation. Contract-first source of truth (spec
+ 007 / T009 and spec 009 / T081).
contact:
name: MicroTodoSuite
url: https://github.com/MicroTodoSuite
@@ -69,28 +70,44 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/Error'
+ /health/startup:
+ get:
+ operationId: getStartupHealth
+ tags: [observability]
+ summary: Actuator startup health group (public).
+ description: Reports whether the application context has completed startup.
+ responses:
+ '200':
+ $ref: '#/components/responses/Health'
+ /health/readiness:
+ get:
+ operationId: getReadinessHealth
+ tags: [observability]
+ summary: Actuator readiness health group (public).
+ description: Reports whether the API and its database dependency can serve traffic.
+ responses:
+ '200':
+ $ref: '#/components/responses/Health'
+ '503':
+ $ref: '#/components/responses/Health'
+ /health/liveness:
+ get:
+ operationId: getLivenessHealth
+ tags: [observability]
+ summary: Actuator liveness health group (public).
+ description: Reports whether the running process is alive.
+ responses:
+ '200':
+ $ref: '#/components/responses/Health'
/health:
get:
- operationId: getHealth
+ operationId: getAggregateHealth
tags: [observability]
- summary: Actuator health (public).
- description: Spring Boot actuator health endpoint.
+ summary: Aggregate Actuator health (public).
+ description: Returns the aggregate status without exposing component details.
responses:
'200':
- description: Health status.
- content:
- application/vnd.spring-boot.actuator.v3+json:
- schema:
- type: object
- properties:
- status:
- type: string
- application/json:
- schema:
- type: object
- properties:
- status:
- type: string
+ $ref: '#/components/responses/Health'
/prometheus:
get:
operationId: getPrometheus
@@ -105,12 +122,31 @@ paths:
schema:
type: string
components:
+ headers:
+ CorrelationId:
+ description: Correlation identifier adopted or generated for this request.
+ schema:
+ type: string
+ minLength: 1
+ maxLength: 128
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
responses:
+ Health:
+ description: Actuator health status.
+ headers:
+ X-Request-Id:
+ $ref: '#/components/headers/CorrelationId'
+ content:
+ application/vnd.spring-boot.actuator.v3+json:
+ schema:
+ $ref: '#/components/schemas/Health'
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Health'
Unauthorized:
description: Missing or invalid bearer token.
content:
@@ -118,6 +154,13 @@ components:
schema:
$ref: '#/components/schemas/Error'
schemas:
+ Health:
+ type: object
+ required: [status]
+ properties:
+ status:
+ type: string
+ enum: [UP, DOWN, OUT_OF_SERVICE, UNKNOWN]
User:
type: object
required: [username]
diff --git a/pom.xml b/pom.xml
index ea84efd..ebcfebe 100644
--- a/pom.xml
+++ b/pom.xml
@@ -44,11 +44,11 @@
io.micrometer
- micrometer-tracing-bridge-brave
+ micrometer-tracing-bridge-otel
- io.zipkin.reporter2
- zipkin-reporter-brave
+ io.opentelemetry
+ opentelemetry-exporter-zipkin
com.h2database
diff --git a/src/main/java/com/elgris/usersapi/UsersApiApplication.java b/src/main/java/com/elgris/usersapi/UsersApiApplication.java
index 3cce300..e784300 100644
--- a/src/main/java/com/elgris/usersapi/UsersApiApplication.java
+++ b/src/main/java/com/elgris/usersapi/UsersApiApplication.java
@@ -1,7 +1,13 @@
package com.elgris.usersapi;
+import com.elgris.usersapi.configuration.OperationalProperties;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.boot.ApplicationRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
+import org.springframework.context.annotation.Bean;
/**
* The entry point for the Users API application.
@@ -11,8 +17,11 @@
*
*/
@SpringBootApplication
+@ConfigurationPropertiesScan
public class UsersApiApplication {
+ private static final Logger LOGGER = LoggerFactory.getLogger(UsersApiApplication.class);
+
/**
* The main method that starts the Spring Boot application.
*
@@ -22,7 +31,11 @@ public class UsersApiApplication {
* @param args Command-line arguments passed to the application.
*/
public static void main(String[] args) {
- // Run the Spring Boot application
SpringApplication.run(UsersApiApplication.class, args);
- }
+ }
+
+ @Bean
+ ApplicationRunner logOperationalConfiguration(OperationalProperties properties) {
+ return arguments -> LOGGER.info("Operational configuration loaded: {}", properties);
+ }
}
diff --git a/src/main/java/com/elgris/usersapi/api/CounterController.java b/src/main/java/com/elgris/usersapi/api/CounterController.java
index d6c55ed..39c0be1 100644
--- a/src/main/java/com/elgris/usersapi/api/CounterController.java
+++ b/src/main/java/com/elgris/usersapi/api/CounterController.java
@@ -3,12 +3,15 @@
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import java.util.concurrent.atomic.AtomicInteger;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class CounterController {
+ private static final Logger LOGGER = LoggerFactory.getLogger(CounterController.class);
private final Counter requests;
private final AtomicInteger count = new AtomicInteger();
@@ -21,6 +24,8 @@ public CounterController(MeterRegistry meterRegistry) {
@GetMapping("/count")
public int count() {
requests.increment();
- return count.incrementAndGet();
+ int current = count.incrementAndGet();
+ LOGGER.info("Request counter incremented to {}", current);
+ return current;
}
}
diff --git a/src/main/java/com/elgris/usersapi/api/UsersController.java b/src/main/java/com/elgris/usersapi/api/UsersController.java
index 2fc0fe8..b67bea6 100644
--- a/src/main/java/com/elgris/usersapi/api/UsersController.java
+++ b/src/main/java/com/elgris/usersapi/api/UsersController.java
@@ -7,6 +7,8 @@
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@@ -17,6 +19,7 @@
@RequestMapping("/users")
public class UsersController {
+ private static final Logger LOGGER = LoggerFactory.getLogger(UsersController.class);
private final UserRepository userRepository;
public UsersController(UserRepository userRepository) {
@@ -28,6 +31,7 @@ public UsersController(UserRepository userRepository) {
public List getUsers() {
List response = new LinkedList<>();
userRepository.findAll().forEach(response::add);
+ LOGGER.info("User directory listing completed with {} entries", response.size());
return response;
}
@@ -41,6 +45,8 @@ public User getUser(HttpServletRequest request, @PathVariable String username) {
if (!username.equalsIgnoreCase(String.valueOf(claims.get("username")))) {
throw new AccessDeniedException("No access for requested entity");
}
- return userRepository.findOneByUsername(username);
+ User user = userRepository.findOneByUsername(username);
+ LOGGER.info("User lookup completed");
+ return user;
}
}
diff --git a/src/main/java/com/elgris/usersapi/configuration/CorrelationIdFilter.java b/src/main/java/com/elgris/usersapi/configuration/CorrelationIdFilter.java
new file mode 100644
index 0000000..6f20d6b
--- /dev/null
+++ b/src/main/java/com/elgris/usersapi/configuration/CorrelationIdFilter.java
@@ -0,0 +1,40 @@
+package com.elgris.usersapi.configuration;
+
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.util.UUID;
+import java.util.regex.Pattern;
+import org.slf4j.MDC;
+import org.springframework.core.Ordered;
+import org.springframework.core.annotation.Order;
+import org.springframework.stereotype.Component;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+/** Adopts or creates the request identifier shared by logs and callers. */
+@Component
+@Order(Ordered.HIGHEST_PRECEDENCE)
+public class CorrelationIdFilter extends OncePerRequestFilter {
+
+ static final String HEADER = "X-Request-Id";
+ private static final Pattern SAFE_ID = Pattern.compile("[A-Za-z0-9._:-]{1,128}");
+
+ @Override
+ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
+ throws ServletException, IOException {
+ String candidate = request.getHeader(HEADER);
+ String correlationId = candidate != null && SAFE_ID.matcher(candidate).matches()
+ ? candidate
+ : UUID.randomUUID().toString();
+
+ response.setHeader(HEADER, correlationId);
+ MDC.put("correlationId", correlationId);
+ try {
+ chain.doFilter(request, response);
+ } finally {
+ MDC.remove("correlationId");
+ }
+ }
+}
diff --git a/src/main/java/com/elgris/usersapi/configuration/OperationalProperties.java b/src/main/java/com/elgris/usersapi/configuration/OperationalProperties.java
new file mode 100644
index 0000000..e6b73df
--- /dev/null
+++ b/src/main/java/com/elgris/usersapi/configuration/OperationalProperties.java
@@ -0,0 +1,18 @@
+package com.elgris.usersapi.configuration;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+
+/**
+ * Non-secret runtime configuration controlled independently from the image.
+ * JWT material deliberately does not belong to this loggable object.
+ */
+@ConfigurationProperties(prefix = "users.operational")
+public record OperationalProperties(Features features) {
+
+ public OperationalProperties {
+ features = features == null ? new Features(false) : features;
+ }
+
+ public record Features(boolean verboseSecurityErrors) {
+ }
+}
diff --git a/src/main/java/com/elgris/usersapi/configuration/SecurityConfiguration.java b/src/main/java/com/elgris/usersapi/configuration/SecurityConfiguration.java
index 745fc6c..4aa44c0 100644
--- a/src/main/java/com/elgris/usersapi/configuration/SecurityConfiguration.java
+++ b/src/main/java/com/elgris/usersapi/configuration/SecurityConfiguration.java
@@ -5,6 +5,7 @@
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
+import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@@ -16,6 +17,7 @@ SecurityFilterChain apiSecurity(HttpSecurity http, JwtAuthenticationFilter jwtAu
throws Exception {
return http
.csrf(AbstractHttpConfigurer::disable)
+ .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(authorize -> authorize.anyRequest().permitAll())
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.build();
diff --git a/src/main/java/com/elgris/usersapi/security/JwtAuthenticationFilter.java b/src/main/java/com/elgris/usersapi/security/JwtAuthenticationFilter.java
index 1f564cc..eebe8e0 100644
--- a/src/main/java/com/elgris/usersapi/security/JwtAuthenticationFilter.java
+++ b/src/main/java/com/elgris/usersapi/security/JwtAuthenticationFilter.java
@@ -1,5 +1,6 @@
package com.elgris.usersapi.security;
+import com.elgris.usersapi.configuration.OperationalProperties;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.FilterChain;
@@ -26,10 +27,15 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final byte[] jwtSecret;
private final ObjectMapper objectMapper;
+ private final OperationalProperties operationalProperties;
- public JwtAuthenticationFilter(@Value("${jwt.secret}") String jwtSecret, ObjectMapper objectMapper) {
+ public JwtAuthenticationFilter(
+ @Value("${jwt.secret}") String jwtSecret,
+ ObjectMapper objectMapper,
+ OperationalProperties operationalProperties) {
this.jwtSecret = jwtSecret.getBytes(StandardCharsets.UTF_8);
this.objectMapper = objectMapper;
+ this.operationalProperties = operationalProperties;
}
@Override
@@ -49,8 +55,11 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse
try {
request.setAttribute("claims", verify(authHeader.substring(7)));
chain.doFilter(request, response);
- } catch (IllegalArgumentException | GeneralSecurityException exception) {
- response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid token");
+ } catch (IllegalArgumentException | GeneralSecurityException | IOException exception) {
+ String message = operationalProperties.features().verboseSecurityErrors()
+ ? "Invalid token: " + exception.getMessage()
+ : "Invalid token";
+ response.sendError(HttpServletResponse.SC_UNAUTHORIZED, message);
}
}
@@ -59,6 +68,7 @@ private boolean isPublicPath(HttpServletRequest request) {
return "/metrics".equals(path)
|| "/prometheus".equals(path)
|| "/health".equals(path)
+ || path.startsWith("/health/")
|| path.startsWith("/actuator");
}
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
index cb0e8b5..e8578f9 100644
--- a/src/main/resources/application.properties
+++ b/src/main/resources/application.properties
@@ -1,16 +1,26 @@
jwt.secret=${JWT_SECRET:myfancysecret}
server.port=${SERVER_PORT:8083}
spring.application.name=${SPRING_APPLICATION_NAME:users-api}
+spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration
spring.jpa.defer-datasource-initialization=true
spring.jpa.hibernate.ddl-auto=create-drop
+spring.jpa.open-in-view=false
spring.sql.init.mode=always
management.endpoints.web.base-path=/
management.endpoints.web.exposure.include=health,prometheus
-management.endpoint.health.show-details=always
-management.tracing.sampling.probability=${SLEUTH_SAMPLER_PROBABILITY:1.0}
+management.endpoint.health.show-details=never
+management.endpoint.health.probes.enabled=true
+management.endpoint.health.group.startup.include=livenessState
+management.endpoint.health.group.readiness.include=readinessState,db
+management.endpoint.health.group.liveness.include=livenessState
+management.tracing.sampling.probability=${OTEL_TRACES_SAMPLER_ARG:${SLEUTH_SAMPLER_PROBABILITY:1.0}}
management.zipkin.tracing.endpoint=${ZIPKIN_URL:http://zipkin:9411/api/v2/spans}
+management.opentelemetry.resource-attributes.service.name=${spring.application.name}
+
+server.shutdown=graceful
+users.operational.features.verbose-security-errors=${USERS_API_FEATURE_VERBOSE_SECURITY_ERRORS:false}
# Without this, http.server.requests is a Micrometer Summary (count+sum+max
# only); the golden-signal latency dashboard needs real histogram buckets
diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml
index ed03eae..bcbcffa 100644
--- a/src/main/resources/logback-spring.xml
+++ b/src/main/resources/logback-spring.xml
@@ -1,4 +1,7 @@
+
+