Skip to content

Commit 306c6e9

Browse files
authored
add role header propagation for cross-service requests (#144)
Signed-off-by: achour94 <[email protected]>
1 parent 72888c1 commit 306c6e9

File tree

2 files changed

+189
-0
lines changed

2 files changed

+189
-0
lines changed

src/main/java/org/gridsuite/explore/server/RestTemplateConfig.java

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,23 @@
88

99
import com.fasterxml.jackson.databind.ObjectMapper;
1010
import com.fasterxml.jackson.databind.SerializationFeature;
11+
import jakarta.servlet.http.HttpServletRequest;
1112
import org.springframework.boot.jackson.JsonComponentModule;
1213
import org.springframework.context.annotation.Bean;
1314
import org.springframework.context.annotation.Configuration;
15+
import org.springframework.http.HttpRequest;
16+
import org.springframework.http.client.ClientHttpRequestExecution;
17+
import org.springframework.http.client.ClientHttpRequestInterceptor;
18+
import org.springframework.http.client.ClientHttpResponse;
1419
import org.springframework.http.converter.HttpMessageConverter;
1520
import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
1621
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
1722
import org.springframework.web.client.RestTemplate;
23+
import org.springframework.web.context.request.RequestContextHolder;
24+
import org.springframework.web.context.request.ServletRequestAttributes;
25+
26+
import java.io.IOException;
27+
import java.util.Collections;
1828

1929
/**
2030
* @author Etienne Homer <etienne.homer at rte-france.com>
@@ -32,11 +42,47 @@ public RestTemplate restTemplate() {
3242
if (httpMessageConverter instanceof MappingJackson2HttpMessageConverter) {
3343
restTemplate.getMessageConverters().set(i, mappingJackson2HttpMessageConverter());
3444
}
45+
46+
restTemplate.setInterceptors(
47+
Collections.singletonList(new RoleHeaderForwardingInterceptor())
48+
);
3549
}
3650

3751
return restTemplate;
3852
}
3953

54+
/**
55+
* In our microservice architecture, user permissions (roles) must be preserved when
56+
* one service calls another. This interceptor automatically forwards the "roles" header
57+
* from incoming requests to any outgoing REST calls made with this RestTemplate.
58+
*
59+
* Without this interceptor, authorization information would be lost in service-to-service
60+
* communication.
61+
*/
62+
public static class RoleHeaderForwardingInterceptor implements ClientHttpRequestInterceptor {
63+
64+
private static final String ROLES_HEADER = "roles";
65+
66+
@Override
67+
public ClientHttpResponse intercept(HttpRequest request, byte[] body,
68+
ClientHttpRequestExecution execution) throws IOException {
69+
ServletRequestAttributes attributes =
70+
(ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
71+
72+
// If we have a current request, copy its roles header to the outgoing request
73+
if (attributes != null) {
74+
HttpServletRequest currentRequest = attributes.getRequest();
75+
String roles = currentRequest.getHeader(ROLES_HEADER);
76+
77+
if (roles != null && !roles.isEmpty()) {
78+
request.getHeaders().set(ROLES_HEADER, roles);
79+
}
80+
}
81+
82+
return execution.execute(request, body);
83+
}
84+
}
85+
4086
public MappingJackson2HttpMessageConverter mappingJackson2HttpMessageConverter() {
4187
MappingJackson2HttpMessageConverter converter = new MappingJackson2HttpMessageConverter();
4288
converter.setObjectMapper(objectMapper());
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
/**
2+
* Copyright (c) 2025, RTE (http://www.rte-france.com)
3+
* This Source Code Form is subject to the terms of the Mozilla Public
4+
* License, v. 2.0. If a copy of the MPL was not distributed with this
5+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
6+
*/
7+
package org.gridsuite.explore.server;
8+
9+
import org.junit.jupiter.api.AfterEach;
10+
import org.junit.jupiter.api.BeforeEach;
11+
import org.junit.jupiter.api.Test;
12+
import org.junit.jupiter.api.extension.ExtendWith;
13+
import org.springframework.beans.factory.annotation.Autowired;
14+
import org.springframework.http.HttpMethod;
15+
import org.springframework.http.MediaType;
16+
import org.springframework.mock.web.MockHttpServletRequest;
17+
import org.springframework.test.context.ContextConfiguration;
18+
import org.springframework.test.context.junit.jupiter.SpringExtension;
19+
import org.springframework.test.web.client.MockRestServiceServer;
20+
import org.springframework.test.web.client.response.MockRestResponseCreators;
21+
import org.springframework.web.client.RestTemplate;
22+
import org.springframework.web.context.request.RequestContextHolder;
23+
import org.springframework.web.context.request.ServletRequestAttributes;
24+
25+
import static org.springframework.test.web.client.match.MockRestRequestMatchers.*;
26+
27+
28+
/**
29+
* @author Achour Berrahma <achour.berrahma at rte-france.com>
30+
*/
31+
@ExtendWith(SpringExtension.class)
32+
@ContextConfiguration(classes = {RestTemplateConfig.class})
33+
class RestTemplateConfigTest {
34+
35+
@Autowired
36+
private RestTemplate restTemplate;
37+
38+
private MockRestServiceServer mockServer;
39+
private static final String ROLES_HEADER = "roles";
40+
private static final String TEST_ROLES = "ADMIN|USER";
41+
private static final String TEST_ENDPOINT = "http://test-service/api/resource";
42+
43+
@BeforeEach
44+
void setUp() {
45+
mockServer = MockRestServiceServer.createServer(restTemplate);
46+
}
47+
48+
@AfterEach
49+
void tearDown() {
50+
// Clean up the RequestContextHolder after each test
51+
RequestContextHolder.resetRequestAttributes();
52+
}
53+
54+
@Test
55+
void testRoleHeaderIsPropagated() {
56+
// Setup mock incoming request with roles header
57+
MockHttpServletRequest request = new MockHttpServletRequest();
58+
request.addHeader(ROLES_HEADER, TEST_ROLES);
59+
RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request));
60+
61+
// Setup mock response for the outgoing request
62+
mockServer.expect(requestTo(TEST_ENDPOINT))
63+
.andExpect(method(HttpMethod.GET))
64+
.andExpect(header(ROLES_HEADER, TEST_ROLES)) // This verifies our interceptor works
65+
.andRespond(MockRestResponseCreators.withSuccess("{\"result\":\"success\"}", MediaType.APPLICATION_JSON));
66+
67+
// Execute request through our RestTemplate
68+
restTemplate.getForObject(TEST_ENDPOINT, String.class);
69+
70+
// Verify the request was made correctly
71+
mockServer.verify();
72+
}
73+
74+
@Test
75+
void testNoRoleHeaderPropagationWhenNotPresent() {
76+
// Setup mock incoming request WITHOUT roles header
77+
MockHttpServletRequest request = new MockHttpServletRequest();
78+
RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request));
79+
80+
// Setup mock response - here we expect NOT to see the roles header
81+
mockServer.expect(requestTo(TEST_ENDPOINT))
82+
.andExpect(method(HttpMethod.GET))
83+
.andExpect(req -> {
84+
// Verify the header isn't present (would throw if present)
85+
if (req.getHeaders().containsKey(ROLES_HEADER)) {
86+
throw new AssertionError("Roles header should not be present");
87+
}
88+
})
89+
.andRespond(MockRestResponseCreators.withSuccess("{\"result\":\"success\"}", MediaType.APPLICATION_JSON));
90+
91+
// Execute request
92+
restTemplate.getForObject(TEST_ENDPOINT, String.class);
93+
94+
// Verify
95+
mockServer.verify();
96+
}
97+
98+
@Test
99+
void testEmptyRoleHeaderNotPropagated() {
100+
// Setup mock incoming request with EMPTY roles header
101+
MockHttpServletRequest request = new MockHttpServletRequest();
102+
request.addHeader(ROLES_HEADER, ""); // Empty value
103+
RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request));
104+
105+
// Setup mock - we don't expect the header to be forwarded if empty
106+
mockServer.expect(requestTo(TEST_ENDPOINT))
107+
.andExpect(method(HttpMethod.GET))
108+
.andExpect(req -> {
109+
if (req.getHeaders().containsKey(ROLES_HEADER)) {
110+
throw new AssertionError("Roles header should not be present when empty");
111+
}
112+
})
113+
.andRespond(MockRestResponseCreators.withSuccess("{\"result\":\"success\"}", MediaType.APPLICATION_JSON));
114+
115+
// Execute request
116+
restTemplate.getForObject(TEST_ENDPOINT, String.class);
117+
118+
// Verify
119+
mockServer.verify();
120+
}
121+
122+
@Test
123+
void testContextHolderIsNull() {
124+
// Make sure the context holder is null
125+
RequestContextHolder.resetRequestAttributes();
126+
127+
// Setup mock - we don't expect any header forwarding when no request context exists
128+
mockServer.expect(requestTo(TEST_ENDPOINT))
129+
.andExpect(method(HttpMethod.GET))
130+
.andExpect(request -> {
131+
if (request.getHeaders().containsKey(ROLES_HEADER)) {
132+
throw new AssertionError("Roles header should not be present when RequestContextHolder is null");
133+
}
134+
})
135+
.andRespond(MockRestResponseCreators.withSuccess("{\"result\":\"success\"}", MediaType.APPLICATION_JSON));
136+
137+
// Execute request
138+
restTemplate.getForObject(TEST_ENDPOINT, String.class);
139+
140+
// Verify
141+
mockServer.verify();
142+
}
143+
}

0 commit comments

Comments
 (0)