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
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 |
| Java | OpenJDK 21 |
81 changes: 62 additions & 19 deletions contracts/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -105,19 +122,45 @@ 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:
application/json:
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]
Expand Down
6 changes: 3 additions & 3 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,11 @@
</dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-tracing-bridge-brave</artifactId>
<artifactId>micrometer-tracing-bridge-otel</artifactId>
</dependency>
<dependency>
<groupId>io.zipkin.reporter2</groupId>
<artifactId>zipkin-reporter-brave</artifactId>
<groupId>io.opentelemetry</groupId>
<artifactId>opentelemetry-exporter-zipkin</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
Expand Down
17 changes: 15 additions & 2 deletions src/main/java/com/elgris/usersapi/UsersApiApplication.java
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -11,8 +17,11 @@
* </p>
*/
@SpringBootApplication
@ConfigurationPropertiesScan
public class UsersApiApplication {

private static final Logger LOGGER = LoggerFactory.getLogger(UsersApiApplication.class);

/**
* The main method that starts the Spring Boot application.
* <p>
Expand All @@ -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);
}
}
7 changes: 6 additions & 1 deletion src/main/java/com/elgris/usersapi/api/CounterController.java
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -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;
}
}
8 changes: 7 additions & 1 deletion src/main/java/com/elgris/usersapi/api/UsersController.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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) {
Expand All @@ -28,6 +31,7 @@ public UsersController(UserRepository userRepository) {
public List<User> getUsers() {
List<User> response = new LinkedList<>();
userRepository.findAll().forEach(response::add);
LOGGER.info("User directory listing completed with {} entries", response.size());
return response;
}

Expand All @@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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");
}
}
}
Original file line number Diff line number Diff line change
@@ -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) {
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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();
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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
Expand All @@ -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);
}
}

Expand All @@ -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");
}

Expand Down
Loading
Loading