Implement consul config permission mapping resolving with fallback to application yaml #29
@@ -30,7 +30,19 @@
|
||||
<properties>
|
||||
<java.version>21</java.version>
|
||||
<start-class>com.cleverthis.authservice.AuthServiceApplication</start-class>
|
||||
<spring-cloud.version>2024.0.1</spring-cloud.version>
|
||||
</properties>
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.cloud</groupId>
|
||||
<artifactId>spring-cloud-dependencies</artifactId>
|
||||
<version>${spring-cloud.version}</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
@@ -56,6 +68,10 @@
|
||||
<artifactId>reactor-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.cloud</groupId>
|
||||
<artifactId>spring-cloud-starter-consul-config</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
|
||||
@@ -4,7 +4,10 @@ import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
@@ -28,6 +31,9 @@ public class PermissionMappingConfig {
|
||||
* mapping request paths to Keycloak Client IDs.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class Rule {
|
||||
@NotBlank(message = "Path prefix cannot be blank in permission mapping rule")
|
||||
private String pathPrefix;
|
||||
|
||||
@@ -7,6 +7,7 @@ import com.cleverthis.authservice.dto.TokenResponse;
|
||||
import com.cleverthis.authservice.dto.VerificationResponse;
|
||||
import com.cleverthis.authservice.exception.TokenVerificationException;
|
||||
import com.cleverthis.authservice.service.IdentityManagerService;
|
||||
import com.cleverthis.authservice.service.PermissionMapLookupService;
|
||||
import jakarta.validation.Valid;
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
@@ -53,13 +54,15 @@ public class AuthController {
|
||||
// private static final String HEADER_X_FORWARDED_PROTO = "X-Forwarded-Proto";
|
||||
|
||||
private final IdentityManagerService identityManagerService;
|
||||
private final PermissionMappingConfig permissionMappingConfig; // Inject mapping config
|
||||
private final PermissionMapLookupService permissionMapLookupService;
|
||||
|
||||
@Autowired
|
||||
public AuthController(IdentityManagerService identityManagerService,
|
||||
PermissionMappingConfig permissionMappingConfig) {
|
||||
public AuthController(
|
||||
IdentityManagerService identityManagerService,
|
||||
PermissionMapLookupService permissionMapLookupService
|
||||
) {
|
||||
this.identityManagerService = identityManagerService;
|
||||
this.permissionMappingConfig = permissionMappingConfig;
|
||||
this.permissionMapLookupService = permissionMapLookupService;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -270,19 +273,16 @@ public class AuthController {
|
||||
String path = uri.getPath(); // Get path part, e.g., /cleverswarm/api/jobs/123
|
||||
log.debug("Mapping URI path: {}", path);
|
||||
|
||||
for (PermissionMappingConfig.Rule rule : this.permissionMappingConfig.getRules()) {
|
||||
if (path.startsWith(rule.getPathPrefix())) {
|
||||
log.debug("Path '{}' matched prefix '{}', using Keycloak Client ID: {}", path,
|
||||
rule.getPathPrefix(), rule.getKeycloakClientId());
|
||||
return Optional.of(rule.getKeycloakClientId());
|
||||
}
|
||||
final var pathMatch = this.permissionMapLookupService.matchClientIdByPath(path);
|
||||
if (pathMatch.isPresent()) {
|
||||
return pathMatch;
|
||||
}
|
||||
if (this.permissionMappingConfig.getDefaultKeycloakClientId() != null) {
|
||||
final var defaultValue = this.permissionMapLookupService.getDefaultKeycloakClientId();
|
||||
if (defaultValue != null) {
|
||||
log.debug(
|
||||
"No specific prefix matched for path '{}',"
|
||||
+ "using default Keycloak Client ID:{}",
|
||||
path, this.permissionMappingConfig.getDefaultKeycloakClientId());
|
||||
return Optional.of(this.permissionMappingConfig.getDefaultKeycloakClientId());
|
||||
"No rule matched for path '{}', using default Keycloak Client ID:{}",
|
||||
path, defaultValue);
|
||||
return Optional.of(defaultValue);
|
||||
}
|
||||
} catch (URISyntaxException e) {
|
||||
log.error("Invalid URI syntax for X-Forwarded-Uri: {}", fullUriString, e);
|
||||
@@ -293,16 +293,16 @@ public class AuthController {
|
||||
|
||||
private String extractServiceRelativePath(String fullUriString, String mappedKeycloakClientId) {
|
||||
// Find the rule that gave us this mappedKeycloakClientId to get its pathPrefix
|
||||
Optional<PermissionMappingConfig.Rule> matchedRule = this.permissionMappingConfig.getRules()
|
||||
.stream().filter(rule -> rule.getKeycloakClientId().equals(mappedKeycloakClientId)
|
||||
&& fullUriString.startsWith(rule.getPathPrefix()))
|
||||
.findFirst();
|
||||
Optional<PermissionMappingConfig.Rule> matchedRule =
|
||||
this.permissionMapLookupService.matchRuleByClientIdAndPath(
|
||||
mappedKeycloakClientId, fullUriString
|
||||
);
|
||||
|
||||
String pathPrefixToRemove = "";
|
||||
if (matchedRule.isPresent()) {
|
||||
pathPrefixToRemove = matchedRule.get().getPathPrefix();
|
||||
} else if (mappedKeycloakClientId
|
||||
.equals(this.permissionMappingConfig.getDefaultKeycloakClientId())) {
|
||||
.equals(this.permissionMapLookupService.getDefaultKeycloakClientId())) {
|
||||
// If it's a default mapping, there's no prefix to remove, use the whole path.
|
||||
// Or, you might decide that default mappings always apply to root paths. This
|
||||
// needs definition.
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.cleverthis.authservice.service;
|
||||
|
||||
import com.cleverthis.authservice.config.PermissionMappingConfig;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Service for looking up the permission map.
|
||||
*/
|
||||
public interface PermissionMapLookupService {
|
||||
/**
|
||||
* The default client id when no rules matched.
|
||||
* If not provided, might be null or empty.
|
||||
*/
|
||||
String getDefaultKeycloakClientId();
|
||||
|
||||
/**
|
||||
* Given the path, find the client id of the first rule matching the path prefix.
|
||||
*/
|
||||
Optional<String> matchClientIdByPath(String path);
|
||||
|
||||
/**
|
||||
* Given the client id and path, find the rule that both matches the client id exactly,
|
||||
* and match the path prefix.
|
||||
*/
|
||||
Optional<PermissionMappingConfig.Rule> matchRuleByClientIdAndPath(String clientId, String path);
|
||||
}
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
package com.cleverthis.authservice.service.impl;
|
||||
|
||||
import com.cleverthis.authservice.config.PermissionMappingConfig;
|
||||
import com.cleverthis.authservice.service.PermissionMapLookupService;
|
||||
import com.ecwid.consul.v1.ConsulClient;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* {@link PermissionMapLookupService} with fallback.
|
||||
* <p>
|
||||
* It will first look up consul, if not available,
|
||||
* fallback to {@code application.yaml}
|
||||
* </p>
|
||||
*/
|
||||
@Service
|
||||
@Slf4j
|
||||
@EnableScheduling
|
||||
public final class FallbackPermissionMapLookupService implements PermissionMapLookupService {
|
||||
/**
|
||||
* The consul kv key for permission mapping.
|
||||
*/
|
||||
static final String PERMISSION_MAPPING_CONSUL_KEY =
|
||||
"cleverthis/clevermicro/auth-service/config/permission-mapping";
|
||||
private final PermissionMappingConfig permissionMappingConfig;
|
||||
private final ConsulClient consulClient;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
/**
|
||||
* Create an instance of {@link FallbackPermissionMapLookupService}.
|
||||
*
|
||||
* @param permissionMappingConfig the local config, used as fallback.
|
||||
* @param consulClient the consul client, used for query consul value.
|
||||
* @param objectMapper for deserializing the value from consul.
|
||||
*/
|
||||
@Autowired
|
||||
public FallbackPermissionMapLookupService(
|
||||
final PermissionMappingConfig permissionMappingConfig,
|
||||
final ConsulClient consulClient,
|
||||
final ObjectMapper objectMapper
|
||||
) {
|
||||
this.permissionMappingConfig = permissionMappingConfig;
|
||||
this.consulClient = consulClient;
|
||||
this.objectMapper = objectMapper;
|
||||
refreshConsul(); // initial refresh
|
||||
}
|
||||
|
||||
private final AtomicReference<PermissionMappingConfig> consulValue = new AtomicReference<>();
|
||||
|
||||
@Scheduled(initialDelay = 1000, fixedRate = 1000)
|
||||
void refreshConsul() {
|
||||
try {
|
||||
// clear cache
|
||||
this.consulValue.set(null);
|
||||
// fetch value
|
||||
final var resp = this.consulClient.getKVValue(PERMISSION_MAPPING_CONSUL_KEY);
|
||||
if (resp == null) {
|
||||
return; // skip if no response
|
||||
}
|
||||
final var kvValue = resp.getValue();
|
||||
if (kvValue.getDecodedValue() == null) {
|
||||
// skip if no value provided
|
||||
return;
|
||||
}
|
||||
try { // try decoding
|
||||
final var config = this.objectMapper.readValue(
|
||||
kvValue.getDecodedValue(), PermissionMappingConfig.class);
|
||||
this.consulValue.set(config);
|
||||
} catch (final JsonProcessingException ex) {
|
||||
// got a corrupted value, leaving the cache empty
|
||||
// will fall back to local value when query
|
||||
log.error("Corrupted config value from consul for key {}: {}",
|
||||
PERMISSION_MAPPING_CONSUL_KEY, kvValue.getValue(), ex);
|
||||
}
|
||||
} catch (final Throwable ex) {
|
||||
// catch-all
|
||||
log.error("Unknown error when updating consul value for permission mapping", ex);
|
||||
}
|
||||
}
|
||||
|
||||
PermissionMappingConfig getConfig() {
|
||||
final var consulValue = this.consulValue.get();
|
||||
if (consulValue != null) {
|
||||
return consulValue;
|
||||
} else {
|
||||
return this.permissionMappingConfig;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getDefaultKeycloakClientId() {
|
||||
return getConfig().getDefaultKeycloakClientId();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<String> matchClientIdByPath(final String path) {
|
||||
final var config = getConfig();
|
||||
for (final var rule : config.getRules()) {
|
||||
if (path.startsWith(rule.getPathPrefix())) {
|
||||
log.debug("Path '{}' matched prefix '{}', using Keycloak Client ID: {}",
|
||||
path, rule.getPathPrefix(), rule.getKeycloakClientId());
|
||||
return Optional.of(rule.getKeycloakClientId());
|
||||
}
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<PermissionMappingConfig.Rule> matchRuleByClientIdAndPath(
|
||||
final String clientId, final String path
|
||||
) {
|
||||
final var config = getConfig();
|
||||
return config.getRules().stream()
|
||||
.filter(rule ->
|
||||
rule.getKeycloakClientId().equals(clientId)
|
||||
&& path.startsWith(rule.getPathPrefix()))
|
||||
.findFirst();
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,13 @@ server:
|
||||
spring:
|
||||
application:
|
||||
name: auth-service
|
||||
cloud:
|
||||
consul:
|
||||
host: ${CONSUL_HOST:localhost}
|
||||
port: ${CONSUL_PORT:8500}
|
||||
config:
|
||||
import-check:
|
||||
enabled: false
|
||||
|
||||
# Keycloak Configuration (adjust values as needed)
|
||||
keycloak:
|
||||
@@ -26,12 +33,8 @@ auth-service:
|
||||
# The Keycloak Client ID is the one you set in Keycloak (e.g., "cleverswarm-app")
|
||||
# The 'pathPrefix' will be matched against the X-Forwarded-Uri
|
||||
rules:
|
||||
- pathPrefix: "/cleverswarm/" # If X-Forwarded-Uri starts with /cleverswarm/
|
||||
keycloakClientId: "cleverswarm-client" # ...then permissions are checked against this Keycloak client
|
||||
- pathPrefix: "/cleverbrag/"
|
||||
keycloakClientId: "cleverbrag-client"
|
||||
- pathPrefix: "/test-service/"
|
||||
keycloakClientId: "auth-service"
|
||||
- pathPrefix: "/auth-service/" # If X-Forwarded-Uri starts with /cleverswarm/
|
||||
keycloakClientId: "auth-service" # ...then permissions are checked against this Keycloak client
|
||||
# Add more rules as needed
|
||||
# Default Keycloak Client ID if no prefix matches (optional)
|
||||
# defaultKeycloakClientId: "general-api-client"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.cleverthis.authservice; // Assuming correct package
|
||||
package com.cleverthis.authservice;
|
||||
|
||||
import static org.hamcrest.MatcherAssert.assertThat;
|
||||
import static org.hamcrest.Matchers.equalTo;
|
||||
@@ -17,6 +17,8 @@ import com.cleverthis.authservice.exception.GlobalExceptionHandler;
|
||||
import com.cleverthis.authservice.exception.LogoutException;
|
||||
import com.cleverthis.authservice.exception.TokenVerificationException;
|
||||
import com.cleverthis.authservice.service.IdentityManagerService;
|
||||
import com.cleverthis.authservice.service.impl.FallbackPermissionMapLookupService;
|
||||
import com.ecwid.consul.v1.ConsulClient;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
@@ -26,7 +28,7 @@ import org.mockito.Captor;
|
||||
import org.mockito.Mockito;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.reactive.WebFluxTest;
|
||||
import org.springframework.context.annotation.Import; // Import annotation
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
@@ -38,7 +40,10 @@ import reactor.core.publisher.Mono;
|
||||
* controller logic and request/response handling.
|
||||
*/
|
||||
@WebFluxTest(AuthController.class)
|
||||
@Import({ GlobalExceptionHandler.class, PermissionMappingConfig.class }) class AuthControllerTest {
|
||||
@Import({GlobalExceptionHandler.class,
|
||||
PermissionMappingConfig.class,
|
||||
FallbackPermissionMapLookupService.class})
|
||||
class AuthControllerTest {
|
||||
|
||||
@Autowired
|
||||
private WebTestClient webTestClient;
|
||||
@@ -52,6 +57,11 @@ import reactor.core.publisher.Mono;
|
||||
@Captor
|
||||
private ArgumentCaptor<List<String>> candidateRolesCaptor;
|
||||
|
||||
// required by the fallback permission mapping lookup service
|
||||
// provide a mocked one
|
||||
@MockitoBean
|
||||
private ConsulClient consulClient;
|
||||
|
||||
private TokenResponse mockTokenResponse;
|
||||
private VerificationResponse mockUserVerificationResponse;
|
||||
|
||||
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
package com.cleverthis.authservice.service.impl;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import com.cleverthis.authservice.config.PermissionMappingConfig;
|
||||
import com.ecwid.consul.v1.ConsulClient;
|
||||
import com.ecwid.consul.v1.Response;
|
||||
import com.ecwid.consul.v1.kv.model.GetValue;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.junit.jupiter.api.Assertions;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.Mockito;
|
||||
|
||||
class FallbackPermissionMapLookupServiceTest {
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
private final ConsulClient consulClient = Mockito.mock(ConsulClient.class);
|
||||
|
||||
/**
|
||||
* Test {@link FallbackPermissionMapLookupService#refreshConsul()}}
|
||||
* and {@link FallbackPermissionMapLookupService#getConfig()}
|
||||
* to see if the refresh and fallback is working.
|
||||
*/
|
||||
@Test
|
||||
void testRefreshConsulAndGetConfigFallBack() throws JsonProcessingException {
|
||||
final var service = new FallbackPermissionMapLookupService(
|
||||
null, this.consulClient, this.objectMapper);
|
||||
Mockito.reset(this.consulClient);
|
||||
// null response from consul client
|
||||
service.refreshConsul();
|
||||
Mockito.verify(this.consulClient)
|
||||
.getKVValue(FallbackPermissionMapLookupService.PERMISSION_MAPPING_CONSUL_KEY);
|
||||
assertNull(service.getConfig()); // fallback is null
|
||||
// have response but null value
|
||||
final var resp = Mockito.mock(Response.class);
|
||||
when(this.consulClient.getKVValue(
|
||||
FallbackPermissionMapLookupService.PERMISSION_MAPPING_CONSUL_KEY))
|
||||
.thenReturn(resp);
|
||||
service.refreshConsul();
|
||||
Mockito.verify(resp).getValue();
|
||||
assertNull(service.getConfig()); // fallback is null
|
||||
// has value but null content
|
||||
final var value = Mockito.mock(GetValue.class);
|
||||
when(resp.getValue()).thenReturn(value);
|
||||
service.refreshConsul();
|
||||
Mockito.verify(value).getDecodedValue();
|
||||
assertNull(service.getConfig()); // fallback is null
|
||||
// has content, but not valid json
|
||||
when(value.getDecodedValue()).thenReturn("not-json");
|
||||
Assertions.assertDoesNotThrow(service::refreshConsul);
|
||||
assertNull(service.getConfig()); // fallback is null
|
||||
// has json content
|
||||
when(value.getDecodedValue())
|
||||
.thenReturn(this.objectMapper.writeValueAsString(new PermissionMappingConfig()));
|
||||
service.refreshConsul();
|
||||
assertEquals(new PermissionMappingConfig(), service.getConfig());
|
||||
}
|
||||
|
||||
/**
|
||||
* Test {@link FallbackPermissionMapLookupService#getDefaultKeycloakClientId()}
|
||||
* to ensure it returns the correct default Keycloak client ID when the config is set.
|
||||
*/
|
||||
@Test
|
||||
void testGetDefaultKeycloakClientId() {
|
||||
final var defaultConfig = new PermissionMappingConfig();
|
||||
defaultConfig.setDefaultKeycloakClientId("default-client-id");
|
||||
|
||||
final var service = new FallbackPermissionMapLookupService(
|
||||
defaultConfig, this.consulClient, this.objectMapper);
|
||||
|
||||
assertEquals("default-client-id", service.getDefaultKeycloakClientId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testMatchClientIdByPath() {
|
||||
final var defaultConfig = new PermissionMappingConfig();
|
||||
defaultConfig.setRules(List.of(
|
||||
PermissionMappingConfig.Rule.builder()
|
||||
.keycloakClientId("service-A").pathPrefix("/a/").build(),
|
||||
PermissionMappingConfig.Rule.builder()
|
||||
.keycloakClientId("service-B").pathPrefix("/b/").build()
|
||||
));
|
||||
final var service = new FallbackPermissionMapLookupService(
|
||||
defaultConfig, this.consulClient, this.objectMapper);
|
||||
|
||||
assertEquals(
|
||||
"service-B",
|
||||
service.matchClientIdByPath("/b/items/123").orElseThrow());
|
||||
assertEquals(
|
||||
Optional.empty(),
|
||||
service.matchClientIdByPath("/c/items/123"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testMatchRuleByClientIdAndPath() {
|
||||
final var defaultConfig = new PermissionMappingConfig();
|
||||
defaultConfig.setRules(List.of(
|
||||
PermissionMappingConfig.Rule.builder()
|
||||
.keycloakClientId("service-A").pathPrefix("/a/").build(),
|
||||
PermissionMappingConfig.Rule.builder()
|
||||
.keycloakClientId("service-B").pathPrefix("/b/").build()
|
||||
));
|
||||
final var service = new FallbackPermissionMapLookupService(
|
||||
defaultConfig, this.consulClient, this.objectMapper);
|
||||
|
||||
service.matchRuleByClientIdAndPath("service-A", "/a/items/123")
|
||||
.ifPresentOrElse(
|
||||
rule -> assertEquals("service-A", rule.getKeycloakClientId()),
|
||||
() -> Assertions.fail("No rule matched"));
|
||||
service.matchRuleByClientIdAndPath("service-B", "/a/items/123")
|
||||
.ifPresentOrElse(
|
||||
it -> Assertions.fail("Rule matched but should not have"),
|
||||
() -> {}
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user