Compare commits

..

1 Commits

Author SHA1 Message Date
hurui200320 cc12d773d6 feat(General): implement consul config permission mapping resolving with fallback to application yam
Unit test coverage / gradle-test (push) Failing after 1m47s
CI for publishing docker image / build-and-publish (push) Successful in 2m22s
Unit test coverage / gradle-test (pull_request) Failing after 1m16s
Pull config from consul to resolve permission mapping dynamically

if consul is not available or the

value is corrupted, use settings from local value in application.yaml

ISSUES CLOSED: clevermicro/user-management#23
2025-06-06 16:31:25 +08:00
5 changed files with 75 additions and 210 deletions
@@ -11,12 +11,7 @@ import com.cleverthis.authservice.service.PermissionMapLookupService;
import jakarta.validation.Valid;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpHeaders;
@@ -215,25 +210,20 @@ public class AuthController {
// Path for role construction should be relative to the service prefix
String serviceRelativePath =
extractServiceRelativePath(originalUriString, targetServiceClientId);
List<String> candidateRoleNames =
generateCandidateRoleNames(originalMethod, serviceRelativePath);
String requiredRoleName =
constructRequiredRoleName(originalMethod, serviceRelativePath);
if (candidateRoleNames.isEmpty()) {
log.warn(
"ForwardAuth: No candidate roles generated for Method='{}',"
+ " RelativePath='{}'. Denying access.",
originalMethod, serviceRelativePath);
return Mono.just(ResponseEntity.status(HttpStatus.FORBIDDEN).<Void>build());
}
log.debug("ForwardAuth: UserSub='{}', TargetClient='{}', RequiredRole='{}'",
userSub, targetServiceClientId, requiredRoleName);
// Step 4: Check permission
return this.identityManagerService.checkUserHasAnyClientRole(userSub,
targetServiceClientId, candidateRoleNames).flatMap(hasPermission -> {
return this.identityManagerService.checkUserClientRolePermission(userSub,
targetServiceClientId, requiredRoleName).flatMap(hasPermission -> {
if (hasPermission) {
log.info(
"ForwardAuth: GRANTED for UserSub='{}',"
+ " Role='{}', Client='{}'",
userSub, candidateRoleNames, targetServiceClientId);
userSub, requiredRoleName, targetServiceClientId);
HttpHeaders responseHeaders =
buildAuthHeaders(verificationResponse);
return Mono.just(ResponseEntity.ok().headers(responseHeaders)
@@ -242,7 +232,7 @@ public class AuthController {
log.warn(
"ForwardAuth: DENIED for UserSub='{}',"
+ " Role='{}', Client='{}'",
userSub, candidateRoleNames, targetServiceClientId);
userSub, requiredRoleName, targetServiceClientId);
return Mono.just(ResponseEntity.status(HttpStatus.FORBIDDEN)
.<Void>build());
}
@@ -335,94 +325,37 @@ public class AuthController {
}
/**
* Generates a list of candidate Keycloak Client Role names
* based on HTTP method and relative path,
* ordered from most specific to most general.
* Examples for GET /api/items/123e4567-e89b-12d3-a456-426614174000
* (normalized to /api/items/BY_ID):
* - GET_API_ITEMS_BY_ID
* - ALL_API_ITEMS_BY_ID
* - GET_API_ITEMS_STAR
* - ALL_API_ITEMS_STAR
* - GET_API_STAR
* - ALL_API_STAR
* - GET_STAR
* - ALL_STAR
* Constructs the required Keycloak Client Role name based on the HTTP method and relative path.
* Example: GET /api/jobs/{id} -> GET_api_jobs_BY_ID Example: POST /api/users -> POST_api_users
*/
private List<String> generateCandidateRoleNames(String httpMethod, String relativePath) {
final String httpMethodUpper = httpMethod.toUpperCase();
final Set<String> candidates = new LinkedHashSet<>();
// Normalize path: remove leading slash for segment processing, handle root
private String constructRequiredRoleName(String httpMethod, String relativePath) {
// Normalize path: remove leading/trailing slashes, replace slashes with underscores
String normalizedPath = relativePath.trim();
if (normalizedPath.startsWith("/")) {
normalizedPath = normalizedPath.substring(1);
}
if (normalizedPath.endsWith("/")) { // Remove trailing slash if not root
if (normalizedPath.length() > 1) {
normalizedPath = normalizedPath.substring(0, normalizedPath.length() - 1);
} else if (normalizedPath.length() == 1 && normalizedPath.equals("/")) {
normalizedPath = ""; // Treat as root
}
if (normalizedPath.endsWith("/")) {
normalizedPath = normalizedPath.substring(0, normalizedPath.length() - 1);
}
// Replace path parameters and UUIDs
String processedPath = normalizedPath.replaceAll("\\{[^}]+}", "BY_ID");
processedPath = processedPath.replaceAll(
// Replace path parameters like {id} or UUIDs with a placeholder like "BY_ID" or "ITEM"
// This is a simple replacement, more robust regex might be needed for complex patterns
normalizedPath = normalizedPath.replaceAll("\\{[^}]+}", "BY_ID"); // {param} -> BY_ID
// Example: Replace UUIDs - adjust regex as needed for your UUID format
normalizedPath = normalizedPath.replaceAll(
"[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}",
"BY_ID");
List<String> segments = new ArrayList<>();
if (!processedPath.isEmpty()) {
segments.addAll(Arrays.asList(processedPath.split("/")));
// Replace remaining slashes with underscores and make uppercase
String rolePathPart = normalizedPath.replace("/", "_").toUpperCase();
if (rolePathPart.isEmpty()) {
rolePathPart = "ROOT"; // Or some other indicator for root path of the service
}
// Generate path-specific role parts
// For path /p1/p2/p3: P1_P2_P3, P1_P2_STAR, P1_STAR, STAR
if (!segments.isEmpty()) {
for (int i = segments.size(); i >= 0; i--) {
StringBuilder pathPartSb = new StringBuilder();
for (int j = 0; j < i; j++) {
pathPartSb.append(segments.get(j).toUpperCase());
if (j < i - 1) {
pathPartSb.append("_");
}
}
if (i < segments.size()) { // Add STAR if not the full specific path
if (pathPartSb.length() > 0) {
pathPartSb.append("_");
}
pathPartSb.append("STAR");
}
String pathRoleSuffix = pathPartSb.toString();
if (pathRoleSuffix.isEmpty() && segments.size() > 0) {
pathRoleSuffix = "STAR";
} else if (pathRoleSuffix.isEmpty() && segments.isEmpty()) { // for root path "/"
pathRoleSuffix = "ROOT"; // or STAR depending on convention
}
// Avoid double STAR for METHOD_STAR_STAR etc.
if (!pathRoleSuffix.isEmpty() && !pathRoleSuffix.equals("STAR")) {
candidates.add(httpMethodUpper + "_" + pathRoleSuffix);
candidates.add("ALL_" + pathRoleSuffix);
// for root path "/" generating METHOD_ROOT and ALL_ROOT
} else if (pathRoleSuffix.equals("STAR") && segments.isEmpty()) {
candidates.add(httpMethodUpper + "_ROOT");
candidates.add("ALL_ROOT");
}
}
} else { // Root path "/"
candidates.add(httpMethodUpper + "_ROOT"); // e.g. GET_ROOT
candidates.add("ALL_ROOT"); // e.g. ALL_ROOT
}
// Add method-wide wildcard and client-wide wildcard
candidates.add(httpMethodUpper + "_STAR");
candidates.add("ALL_STAR");
List<String> orderedCandidates = new ArrayList<>(candidates);
log.debug("Generated candidate roles for method '{}', relative path '{}': {}", httpMethod,
relativePath, orderedCandidates);
return orderedCandidates;
String roleName = httpMethod.toUpperCase() + "_" + rolePathPart;
// Optional: Truncate or hash if role names become too long for Keycloak
log.debug("Constructed role name: {} from method: {} and relative path: {}", roleName,
httpMethod, relativePath);
return roleName;
}
}
@@ -1,10 +1,8 @@
package com.cleverthis.authservice.service;
import com.cleverthis.authservice.dto.RegistrationRequest;
import com.cleverthis.authservice.dto.TokenResponse;
import com.cleverthis.authservice.dto.VerificationResponse;
import java.util.List;
import reactor.core.publisher.Mono; // Using Mono for asynchronous operations with WebClient
/**
@@ -48,12 +46,12 @@ public interface IdentityManagerService {
* @param userSub The subject (ID) of the user.
* @param targetServiceClientId The public client ID of the target service/application
* in Keycloak.
* @param candidateRoleNames The name of the client role to check for.
* @param requiredRoleName The name of the client role to check for.
* @return A Mono emitting true if the user has the role, false otherwise. Emits an error if the
* check cannot be performed (e.g., Keycloak unavailable).
*/
Mono<Boolean> checkUserHasAnyClientRole(String userSub, String targetServiceClientId,
List<String> candidateRoleNames);
Mono<Boolean> checkUserClientRolePermission(String userSub, String targetServiceClientId,
String requiredRoleName);
/**
* Registers a new user in the identity provider.
@@ -12,7 +12,6 @@ import com.cleverthis.authservice.exception.UserAlreadyExistsException;
import com.cleverthis.authservice.service.IdentityManagerService;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
@@ -178,10 +177,10 @@ public class KeycloakIdentityManagerService implements IdentityManagerService {
}
@Override
public Mono<Boolean> checkUserHasAnyClientRole(String userSub, String targetServiceClientId,
List<String> candidateRoleNames) {
public Mono<Boolean> checkUserClientRolePermission(String userSub, String targetServiceClientId,
String requiredRoleName) {
log.info("Checking permission for userSub '{}', targetClient '{}', requiredRole '{}'",
userSub, targetServiceClientId, candidateRoleNames);
userSub, targetServiceClientId, requiredRoleName);
// Step 1: Get the internal UUID of the target Keycloak client
return this.keycloakAdminClient.getClientInternalId(targetServiceClientId)
@@ -194,17 +193,15 @@ public class KeycloakIdentityManagerService implements IdentityManagerService {
}).map(roles -> {
// Step 3: Check if the required role name is present in the list of roles
boolean hasRole = roles.stream()
.anyMatch(role -> candidateRoleNames.contains(role.getName()));
.anyMatch(role -> requiredRoleName.equals(role.getName()));
if (hasRole) {
log.info("Permission GRANTED for userSub '{}' to role '{}' on client '{}'"
+ ". User roles: {}",
userSub, candidateRoleNames, targetServiceClientId,
roles.stream().map(KeycloakRoleRepresentation::getName).toList());
log.info("Permission GRANTED for userSub '{}' to role '{}' on client '{}'",
userSub, requiredRoleName, targetServiceClientId);
} else {
log.warn(
"Permission DENIED for userSub '{}' to role '{}' on"
+ " client '{}'. User roles: {}",
userSub, candidateRoleNames, targetServiceClientId,
userSub, requiredRoleName, targetServiceClientId,
roles.stream().map(KeycloakRoleRepresentation::getName).toList());
}
return hasRole;
@@ -212,7 +209,7 @@ public class KeycloakIdentityManagerService implements IdentityManagerService {
.doOnError(e -> log.error(
"Error during permission check for userSub '{}', "
+ "client '{}', role '{}': {}",
userSub, targetServiceClientId, candidateRoleNames, e.getMessage(), e))
userSub, targetServiceClientId, requiredRoleName, e.getMessage(), e))
// If any error occurs during the admin calls (client not found, user roles fetch
// fail), treat as permission denied.
// Or, you could throw a specific PermissionCheckException to be handled by
+1 -1
View File
@@ -17,7 +17,7 @@ spring:
keycloak:
server-url: ${KEYCLOAK_AUTH_SERVER_URL:http://localhost:9100} # Base URL of your Keycloak instance (NO trailing slash)
realm: ${KEYCLOAK_AUTH_REALM:myrealm} # The realm name you are using
client-id: ${KEYCLOAK_AUTH_SERVICE_CLIENT:auth-service} # Client ID created in Keycloak for this service
client-id: ${KEYCLOAK_AUTH_SERVICE_CLIENT:test-client} # Client ID created in Keycloak for this service
client-secret: ${KEYCLOAK_AUTH_SERVICE_SECRET:7Xyh7M6Tc1FvwznY265KcLzcmXoWmjs6} # Client Secret from Keycloak (use secrets management!)
# Grant type specific settings (ROPC for /login)
grant-type: password
@@ -1,9 +1,6 @@
package com.cleverthis.authservice;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.hasItems;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
import com.cleverthis.authservice.config.PermissionMappingConfig;
@@ -15,6 +12,7 @@ import com.cleverthis.authservice.dto.VerificationResponse;
import com.cleverthis.authservice.exception.AuthenticationException;
import com.cleverthis.authservice.exception.GlobalExceptionHandler;
import com.cleverthis.authservice.exception.LogoutException;
import com.cleverthis.authservice.exception.ServiceUnavailableException;
import com.cleverthis.authservice.exception.TokenVerificationException;
import com.cleverthis.authservice.service.IdentityManagerService;
import com.cleverthis.authservice.service.impl.FallbackPermissionMapLookupService;
@@ -23,9 +21,6 @@ import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
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;
@@ -54,9 +49,6 @@ class AuthControllerTest {
@MockitoBean
private PermissionMappingConfig permissionMappingConfig;
@Captor
private ArgumentCaptor<List<String>> candidateRolesCaptor;
// required by the fallback permission mapping lookup service
// provide a mocked one
@MockitoBean
@@ -185,8 +177,6 @@ class AuthControllerTest {
// --- /auth (ForwardAuth) Tests ---
// --- NEW /auth (ForwardAuth) Tests with Candidate Role List Logic ---
@Test
void forwardAuth_PermissionGranted_ReturnsOkWithUserHeaders() {
String userToken = "user-token-granted";
@@ -195,9 +185,8 @@ class AuthControllerTest {
when(this.identityManagerService.verifyToken(userToken))
.thenReturn(Mono.just(this.mockUserVerificationResponse));
when(this.identityManagerService.checkUserHasAnyClientRole(eq("user-sub-123"),
eq("serviceA-client"), this.candidateRolesCaptor.capture()))
.thenReturn(Mono.just(true));
when(this.identityManagerService.checkUserClientRolePermission("user-sub-123",
"serviceA-client", "GET_API_DATA")).thenReturn(Mono.just(true));
this.webTestClient.post().uri("/auth")
.header(HttpHeaders.AUTHORIZATION, "Bearer " + userToken)
@@ -208,36 +197,23 @@ class AuthControllerTest {
.valueEquals(HEADER_USER_EMAIL, "test@example.com").expectHeader()
.valueEquals(HEADER_USER_GROUPS, "groupA").expectBody().isEmpty();
}
@Test
void forwardAuth_PermissionGranted_SpecificRoleMatch() {
String userToken = "user-token-granted";
String originalMethod = "GET";
// Will be normalized to /api/items/BY_ID
String originalUri = "/serviceA/api/items/123e4567-e89b-12d3-a456-426614174000";
void forwardAuth_PermissionGranted_PathWith_uuid_ReturnsOkWithUserHeaders() {
String userToken = "user-token-uuid";
String originalMethod = "PUT";
String originalUri = "/serviceA/api/items/123e4567-e89b-12d3-a456-426614174000";
when(this.identityManagerService.verifyToken(userToken))
.thenReturn(Mono.just(this.mockUserVerificationResponse));
// Expect checkUserHasAnyClientRole to be called with a list of candidates
// For GET /api/items/BY_ID, candidates would be [GET_API_ITEMS_BY_ID, ALL_API_ITEMS_BY_ID,
// GET_API_ITEMS_STAR, ..., ALL_STAR]
when(this.identityManagerService.checkUserHasAnyClientRole(eq("user-sub-123"),
eq("serviceA-client"), this.candidateRolesCaptor.capture()))
.thenReturn(Mono.just(true));
when(this.identityManagerService.checkUserClientRolePermission("user-sub-123",
"serviceA-client", "PUT_API_ITEMS_BY_ID")).thenReturn(Mono.just(true));
this.webTestClient.post().uri("/auth")
.header(HttpHeaders.AUTHORIZATION, "Bearer " + userToken)
.header(HEADER_X_FORWARDED_METHOD, originalMethod)
.header(HEADER_X_FORWARDED_URI, originalUri).exchange().expectStatus().isOk()
.expectHeader().valueEquals(HEADER_USER_ID, "user-sub-123").expectBody().isEmpty();
// Verify the captured candidate roles
List<String> capturedRoles = this.candidateRolesCaptor.getValue();
assertThat(capturedRoles,
hasItems("GET_API_ITEMS_BY_ID", "ALL_API_ITEMS_BY_ID", "GET_API_ITEMS_STAR",
"ALL_API_ITEMS_STAR", "GET_API_STAR", "ALL_API_STAR", "GET_STAR",
"ALL_STAR"));
// Check order if important, or just presence
}
@Test
@@ -250,9 +226,8 @@ class AuthControllerTest {
when(this.identityManagerService.verifyToken(validToken))
.thenReturn(Mono.just(responseWithoutGroups));
when(this.identityManagerService.checkUserHasAnyClientRole(eq("user-sub-123"),
eq("serviceA-client"), this.candidateRolesCaptor.capture()))
.thenReturn(Mono.just(true));
when(this.identityManagerService.checkUserClientRolePermission("user-sub-123",
"serviceA-client", "GET_API_NOGROUPS")).thenReturn(Mono.just(true));
this.webTestClient.post().uri("/auth")
.header(HttpHeaders.AUTHORIZATION, "Bearer " + validToken)
@@ -263,48 +238,17 @@ class AuthControllerTest {
.valueEquals(HEADER_USER_EMAIL, "test@example.com").expectHeader()
.doesNotExist(HEADER_USER_GROUPS).expectBody().isEmpty();
}
@Test
void forwardAuth_PermissionGranted_WildcardRoleMatch_All_Star() {
String userToken = "user-token-admin";
String originalMethod = "DELETE";
String originalUri = "/serviceA/admin/config"; // -> /admin/config
when(this.identityManagerService.verifyToken(userToken))
.thenReturn(Mono.just(this.mockUserVerificationResponse));
// User has ALL_STAR, so this should pass
when(this.identityManagerService.checkUserHasAnyClientRole(eq("user-sub-123"),
eq("serviceA-client"), anyStringList() // Match any list of strings
)).thenAnswer(invocation -> {
List<String> candidates = invocation.getArgument(2);
// Simulate Keycloak returning true if ALL_STAR is among candidates and user has it
// For this test, we assume the service method checkUserHasAnyClientRole correctly finds
// ALL_STAR
// if the user has it and it's in the candidate list.
// Here, we just return true because the test name implies it should be granted.
// A more robust mock would check if "ALL_STAR" is in `candidates` if the user had
// ALL_STAR.
return Mono.just(true);
});
this.webTestClient.post().uri("/auth")
.header(HttpHeaders.AUTHORIZATION, "Bearer " + userToken)
.header(HEADER_X_FORWARDED_METHOD, originalMethod)
.header(HEADER_X_FORWARDED_URI, originalUri).exchange().expectStatus().isOk()
.expectHeader().valueEquals(HEADER_USER_ID, "user-sub-123").expectBody().isEmpty();
}
@Test
void forwardAuth_PermissionDenied_NoMatchingRole() {
void forwardAuth_PermissionDenied_ReturnsForbidden() {
String userToken = "user-token-denied";
String originalMethod = "POST";
String originalUri = "/serviceA/restricted/action"; // -> /restricted/action
String originalUri = "/serviceA/api/admin";
when(this.identityManagerService.verifyToken(userToken))
.thenReturn(Mono.just(this.mockUserVerificationResponse));
when(this.identityManagerService.checkUserHasAnyClientRole(eq("user-sub-123"),
eq("serviceA-client"), anyStringList() // Match any list of strings
)).thenReturn(Mono.just(false)); // Service reports no matching role found
when(this.identityManagerService.checkUserClientRolePermission("user-sub-123",
"serviceA-client", "POST_API_ADMIN")).thenReturn(Mono.just(false));
this.webTestClient.post().uri("/auth")
.header(HttpHeaders.AUTHORIZATION, "Bearer " + userToken)
@@ -313,27 +257,6 @@ class AuthControllerTest {
.expectBody().isEmpty();
}
@Test
void forwardAuth_RootPath_PermissionGranted() {
String userToken = "user-token-root-access";
String originalMethod = "GET";
String originalUri = "/serviceA/"; // Root path for serviceA
when(this.identityManagerService.verifyToken(userToken))
.thenReturn(Mono.just(this.mockUserVerificationResponse));
when(this.identityManagerService.checkUserHasAnyClientRole(eq("user-sub-123"),
eq("serviceA-client"), this.candidateRolesCaptor.capture()))
.thenReturn(Mono.just(true));
this.webTestClient.post().uri("/auth")
.header(HttpHeaders.AUTHORIZATION, "Bearer " + userToken)
.header(HEADER_X_FORWARDED_METHOD, originalMethod)
.header(HEADER_X_FORWARDED_URI, originalUri).exchange().expectStatus().isOk();
List<String> capturedRoles = this.candidateRolesCaptor.getValue();
assertThat(capturedRoles, hasItems("GET_ROOT", "ALL_ROOT", "GET_STAR", "ALL_STAR"));
}
@Test
void forwardAuth_TokenInvalid_ReturnsUnauthorized() {
String invalidUserToken = "invalid-user-token";
@@ -387,6 +310,25 @@ class AuthControllerTest {
.isForbidden(); // Controller logic for no rule match
}
@Test
void forwardAuth_PermissionCheckServiceError_ReturnsInternalServerError() {
String userToken = "user-token-service-error";
String originalMethod = "GET";
String originalUri = "/serviceA/api/data";
when(this.identityManagerService.verifyToken(userToken))
.thenReturn(Mono.just(this.mockUserVerificationResponse));
when(this.identityManagerService.checkUserClientRolePermission("user-sub-123",
"serviceA-client", "GET_API_DATA")).thenReturn(
Mono.error(new ServiceUnavailableException("Keycloak admin client error")));
this.webTestClient.post().uri("/auth")
.header(HttpHeaders.AUTHORIZATION, "Bearer " + userToken)
.header(HEADER_X_FORWARDED_METHOD, originalMethod)
.header(HEADER_X_FORWARDED_URI, originalUri).exchange().expectStatus()
.is4xxClientError();
}
@Test
void forwardAuth_MissingAuthHeader_ReturnsBadRequest() {
// This tests if @RequestHeader(HttpHeaders.AUTHORIZATION) is enforced
@@ -456,9 +398,4 @@ class AuthControllerTest {
this.webTestClient.post().uri("/logout").contentType(MediaType.APPLICATION_JSON)
.bodyValue(logoutRequest).exchange().expectStatus().isBadRequest(); // Expect 400
}
// Helper to match any List<String> in Mockito
private static List<String> anyStringList() {
return Mockito.anyList();
}
}