feat#3_implement_access_control #22

Merged
hurui200320 merged 2 commits from feat#3_implement_access_control into develop 2025-06-12 11:15:44 +00:00
5 changed files with 216 additions and 80 deletions
@@ -10,7 +10,12 @@ import com.cleverthis.authservice.service.IdentityManagerService;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import java.net.URI; import java.net.URI;
import java.net.URISyntaxException; 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.Optional;
import java.util.Set;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;
@@ -207,20 +212,25 @@ public class AuthController {
// Path for role construction should be relative to the service prefix // Path for role construction should be relative to the service prefix
String serviceRelativePath = String serviceRelativePath =
extractServiceRelativePath(originalUriString, targetServiceClientId); extractServiceRelativePath(originalUriString, targetServiceClientId);
String requiredRoleName = List<String> candidateRoleNames =
constructRequiredRoleName(originalMethod, serviceRelativePath); generateCandidateRoleNames(originalMethod, serviceRelativePath);
log.debug("ForwardAuth: UserSub='{}', TargetClient='{}', RequiredRole='{}'", if (candidateRoleNames.isEmpty()) {
userSub, targetServiceClientId, requiredRoleName); log.warn(
"ForwardAuth: No candidate roles generated for Method='{}',"
+ " RelativePath='{}'. Denying access.",
originalMethod, serviceRelativePath);
return Mono.just(ResponseEntity.status(HttpStatus.FORBIDDEN).<Void>build());
}
// Step 4: Check permission // Step 4: Check permission
return this.identityManagerService.checkUserClientRolePermission(userSub, return this.identityManagerService.checkUserHasAnyClientRole(userSub,
targetServiceClientId, requiredRoleName).flatMap(hasPermission -> { targetServiceClientId, candidateRoleNames).flatMap(hasPermission -> {
if (hasPermission) { if (hasPermission) {
log.info( log.info(
"ForwardAuth: GRANTED for UserSub='{}'," "ForwardAuth: GRANTED for UserSub='{}',"
+ " Role='{}', Client='{}'", + " Role='{}', Client='{}'",
userSub, requiredRoleName, targetServiceClientId); userSub, candidateRoleNames, targetServiceClientId);
HttpHeaders responseHeaders = HttpHeaders responseHeaders =
buildAuthHeaders(verificationResponse); buildAuthHeaders(verificationResponse);
return Mono.just(ResponseEntity.ok().headers(responseHeaders) return Mono.just(ResponseEntity.ok().headers(responseHeaders)
@@ -229,7 +239,7 @@ public class AuthController {
log.warn( log.warn(
"ForwardAuth: DENIED for UserSub='{}'," "ForwardAuth: DENIED for UserSub='{}',"
+ " Role='{}', Client='{}'", + " Role='{}', Client='{}'",
userSub, requiredRoleName, targetServiceClientId); userSub, candidateRoleNames, targetServiceClientId);
return Mono.just(ResponseEntity.status(HttpStatus.FORBIDDEN) return Mono.just(ResponseEntity.status(HttpStatus.FORBIDDEN)
.<Void>build()); .<Void>build());
} }
@@ -325,37 +335,94 @@ public class AuthController {
} }
/** /**
* Constructs the required Keycloak Client Role name based on the HTTP method and relative path. * Generates a list of candidate Keycloak Client Role names
* Example: GET /api/jobs/{id} -> GET_api_jobs_BY_ID Example: POST /api/users -> POST_api_users * 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
*/ */
private String constructRequiredRoleName(String httpMethod, String relativePath) { private List<String> generateCandidateRoleNames(String httpMethod, String relativePath) {
// Normalize path: remove leading/trailing slashes, replace slashes with underscores final String httpMethodUpper = httpMethod.toUpperCase();
final Set<String> candidates = new LinkedHashSet<>();
// Normalize path: remove leading slash for segment processing, handle root
String normalizedPath = relativePath.trim(); String normalizedPath = relativePath.trim();
if (normalizedPath.startsWith("/")) { if (normalizedPath.startsWith("/")) {
normalizedPath = normalizedPath.substring(1); normalizedPath = normalizedPath.substring(1);
} }
if (normalizedPath.endsWith("/")) { if (normalizedPath.endsWith("/")) { // Remove trailing slash if not root
normalizedPath = normalizedPath.substring(0, normalizedPath.length() - 1); if (normalizedPath.length() > 1) {
normalizedPath = normalizedPath.substring(0, normalizedPath.length() - 1);
} else if (normalizedPath.length() == 1 && normalizedPath.equals("/")) {
normalizedPath = ""; // Treat as root
}
} }
// Replace path parameters like {id} or UUIDs with a placeholder like "BY_ID" or "ITEM" // Replace path parameters and UUIDs
// This is a simple replacement, more robust regex might be needed for complex patterns String processedPath = normalizedPath.replaceAll("\\{[^}]+}", "BY_ID");
normalizedPath = normalizedPath.replaceAll("\\{[^}]+}", "BY_ID"); // {param} -> BY_ID processedPath = processedPath.replaceAll(
// 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}", "[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"); "BY_ID");
// Replace remaining slashes with underscores and make uppercase List<String> segments = new ArrayList<>();
String rolePathPart = normalizedPath.replace("/", "_").toUpperCase(); if (!processedPath.isEmpty()) {
if (rolePathPart.isEmpty()) { segments.addAll(Arrays.asList(processedPath.split("/")));
rolePathPart = "ROOT"; // Or some other indicator for root path of the service
} }
String roleName = httpMethod.toUpperCase() + "_" + rolePathPart; // Generate path-specific role parts
// Optional: Truncate or hash if role names become too long for Keycloak // For path /p1/p2/p3: P1_P2_P3, P1_P2_STAR, P1_STAR, STAR
log.debug("Constructed role name: {} from method: {} and relative path: {}", roleName, if (!segments.isEmpty()) {
httpMethod, relativePath); for (int i = segments.size(); i >= 0; i--) {
return roleName; 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;
} }
} }
@@ -1,8 +1,10 @@
package com.cleverthis.authservice.service; package com.cleverthis.authservice.service;
import com.cleverthis.authservice.dto.RegistrationRequest; import com.cleverthis.authservice.dto.RegistrationRequest;
import com.cleverthis.authservice.dto.TokenResponse; import com.cleverthis.authservice.dto.TokenResponse;
import com.cleverthis.authservice.dto.VerificationResponse; import com.cleverthis.authservice.dto.VerificationResponse;
import java.util.List;
import reactor.core.publisher.Mono; // Using Mono for asynchronous operations with WebClient import reactor.core.publisher.Mono; // Using Mono for asynchronous operations with WebClient
/** /**
@@ -46,12 +48,12 @@ public interface IdentityManagerService {
* @param userSub The subject (ID) of the user. * @param userSub The subject (ID) of the user.
* @param targetServiceClientId The public client ID of the target service/application * @param targetServiceClientId The public client ID of the target service/application
* in Keycloak. * in Keycloak.
* @param requiredRoleName The name of the client role to check for. * @param candidateRoleNames 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 * @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). * check cannot be performed (e.g., Keycloak unavailable).
*/ */
Mono<Boolean> checkUserClientRolePermission(String userSub, String targetServiceClientId, Mono<Boolean> checkUserHasAnyClientRole(String userSub, String targetServiceClientId,
String requiredRoleName); List<String> candidateRoleNames);
/** /**
* Registers a new user in the identity provider. * Registers a new user in the identity provider.
@@ -12,6 +12,7 @@ import com.cleverthis.authservice.exception.UserAlreadyExistsException;
import com.cleverthis.authservice.service.IdentityManagerService; import com.cleverthis.authservice.service.IdentityManagerService;
import java.util.Collections; import java.util.Collections;
import java.util.HashMap; import java.util.HashMap;
import java.util.List;
import java.util.Map; import java.util.Map;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
@@ -177,10 +178,10 @@ public class KeycloakIdentityManagerService implements IdentityManagerService {
} }
@Override @Override
public Mono<Boolean> checkUserClientRolePermission(String userSub, String targetServiceClientId, public Mono<Boolean> checkUserHasAnyClientRole(String userSub, String targetServiceClientId,
String requiredRoleName) { List<String> candidateRoleNames) {
log.info("Checking permission for userSub '{}', targetClient '{}', requiredRole '{}'", log.info("Checking permission for userSub '{}', targetClient '{}', requiredRole '{}'",
userSub, targetServiceClientId, requiredRoleName); userSub, targetServiceClientId, candidateRoleNames);
// Step 1: Get the internal UUID of the target Keycloak client // Step 1: Get the internal UUID of the target Keycloak client
return this.keycloakAdminClient.getClientInternalId(targetServiceClientId) return this.keycloakAdminClient.getClientInternalId(targetServiceClientId)
@@ -193,15 +194,17 @@ public class KeycloakIdentityManagerService implements IdentityManagerService {
}).map(roles -> { }).map(roles -> {
// Step 3: Check if the required role name is present in the list of roles // Step 3: Check if the required role name is present in the list of roles
boolean hasRole = roles.stream() boolean hasRole = roles.stream()
.anyMatch(role -> requiredRoleName.equals(role.getName())); .anyMatch(role -> candidateRoleNames.contains(role.getName()));
if (hasRole) { if (hasRole) {
log.info("Permission GRANTED for userSub '{}' to role '{}' on client '{}'", log.info("Permission GRANTED for userSub '{}' to role '{}' on client '{}'"
userSub, requiredRoleName, targetServiceClientId); + ". User roles: {}",
userSub, candidateRoleNames, targetServiceClientId,
roles.stream().map(KeycloakRoleRepresentation::getName).toList());
} else { } else {
log.warn( log.warn(
"Permission DENIED for userSub '{}' to role '{}' on" "Permission DENIED for userSub '{}' to role '{}' on"
+ " client '{}'. User roles: {}", + " client '{}'. User roles: {}",
userSub, requiredRoleName, targetServiceClientId, userSub, candidateRoleNames, targetServiceClientId,
roles.stream().map(KeycloakRoleRepresentation::getName).toList()); roles.stream().map(KeycloakRoleRepresentation::getName).toList());
} }
return hasRole; return hasRole;
@@ -209,7 +212,7 @@ public class KeycloakIdentityManagerService implements IdentityManagerService {
.doOnError(e -> log.error( .doOnError(e -> log.error(
"Error during permission check for userSub '{}', " "Error during permission check for userSub '{}', "
+ "client '{}', role '{}': {}", + "client '{}', role '{}': {}",
userSub, targetServiceClientId, requiredRoleName, e.getMessage(), e)) userSub, targetServiceClientId, candidateRoleNames, e.getMessage(), e))
// If any error occurs during the admin calls (client not found, user roles fetch // If any error occurs during the admin calls (client not found, user roles fetch
// fail), treat as permission denied. // fail), treat as permission denied.
// Or, you could throw a specific PermissionCheckException to be handled by // Or, you could throw a specific PermissionCheckException to be handled by
+2 -2
View File
@@ -10,7 +10,7 @@ spring:
keycloak: keycloak:
server-url: ${KEYCLOAK_AUTH_SERVER_URL:http://localhost:9100} # Base URL of your Keycloak instance (NO trailing slash) 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 realm: ${KEYCLOAK_AUTH_REALM:myrealm} # The realm name you are using
client-id: ${KEYCLOAK_AUTH_SERVICE_CLIENT:test-client} # Client ID created in Keycloak for this service client-id: ${KEYCLOAK_AUTH_SERVICE_CLIENT:auth-service} # Client ID created in Keycloak for this service
client-secret: ${KEYCLOAK_AUTH_SERVICE_SECRET:7Xyh7M6Tc1FvwznY265KcLzcmXoWmjs6} # Client Secret from Keycloak (use secrets management!) client-secret: ${KEYCLOAK_AUTH_SERVICE_SECRET:7Xyh7M6Tc1FvwznY265KcLzcmXoWmjs6} # Client Secret from Keycloak (use secrets management!)
# Grant type specific settings (ROPC for /login) # Grant type specific settings (ROPC for /login)
grant-type: password grant-type: password
@@ -31,7 +31,7 @@ auth-service:
- pathPrefix: "/cleverbrag/" - pathPrefix: "/cleverbrag/"
keycloakClientId: "cleverbrag-client" keycloakClientId: "cleverbrag-client"
- pathPrefix: "/test-service/" - pathPrefix: "/test-service/"
keycloakClientId: "test-client" keycloakClientId: "auth-service"
# Add more rules as needed # Add more rules as needed
# Default Keycloak Client ID if no prefix matches (optional) # Default Keycloak Client ID if no prefix matches (optional)
# defaultKeycloakClientId: "general-api-client" # defaultKeycloakClientId: "general-api-client"
@@ -1,5 +1,9 @@
package com.cleverthis.authservice; package com.cleverthis.authservice; // Assuming correct package
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.equalTo; 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 static org.mockito.Mockito.when;
import com.cleverthis.authservice.config.PermissionMappingConfig; import com.cleverthis.authservice.config.PermissionMappingConfig;
@@ -11,13 +15,15 @@ import com.cleverthis.authservice.dto.VerificationResponse;
import com.cleverthis.authservice.exception.AuthenticationException; import com.cleverthis.authservice.exception.AuthenticationException;
import com.cleverthis.authservice.exception.GlobalExceptionHandler; import com.cleverthis.authservice.exception.GlobalExceptionHandler;
import com.cleverthis.authservice.exception.LogoutException; import com.cleverthis.authservice.exception.LogoutException;
import com.cleverthis.authservice.exception.ServiceUnavailableException;
import com.cleverthis.authservice.exception.TokenVerificationException; import com.cleverthis.authservice.exception.TokenVerificationException;
import com.cleverthis.authservice.service.IdentityManagerService; import com.cleverthis.authservice.service.IdentityManagerService;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; 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.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.reactive.WebFluxTest; import org.springframework.boot.test.autoconfigure.web.reactive.WebFluxTest;
import org.springframework.context.annotation.Import; // Import annotation import org.springframework.context.annotation.Import; // Import annotation
@@ -43,6 +49,9 @@ import reactor.core.publisher.Mono;
@MockitoBean @MockitoBean
private PermissionMappingConfig permissionMappingConfig; private PermissionMappingConfig permissionMappingConfig;
@Captor
private ArgumentCaptor<List<String>> candidateRolesCaptor;
private TokenResponse mockTokenResponse; private TokenResponse mockTokenResponse;
private VerificationResponse mockUserVerificationResponse; private VerificationResponse mockUserVerificationResponse;
@@ -166,6 +175,8 @@ import reactor.core.publisher.Mono;
// --- /auth (ForwardAuth) Tests --- // --- /auth (ForwardAuth) Tests ---
// --- NEW /auth (ForwardAuth) Tests with Candidate Role List Logic ---
@Test @Test
void forwardAuth_PermissionGranted_ReturnsOkWithUserHeaders() { void forwardAuth_PermissionGranted_ReturnsOkWithUserHeaders() {
String userToken = "user-token-granted"; String userToken = "user-token-granted";
@@ -174,8 +185,9 @@ import reactor.core.publisher.Mono;
when(this.identityManagerService.verifyToken(userToken)) when(this.identityManagerService.verifyToken(userToken))
.thenReturn(Mono.just(this.mockUserVerificationResponse)); .thenReturn(Mono.just(this.mockUserVerificationResponse));
when(this.identityManagerService.checkUserClientRolePermission("user-sub-123", when(this.identityManagerService.checkUserHasAnyClientRole(eq("user-sub-123"),
"serviceA-client", "GET_API_DATA")).thenReturn(Mono.just(true)); eq("serviceA-client"), this.candidateRolesCaptor.capture()))
.thenReturn(Mono.just(true));
this.webTestClient.post().uri("/auth") this.webTestClient.post().uri("/auth")
.header(HttpHeaders.AUTHORIZATION, "Bearer " + userToken) .header(HttpHeaders.AUTHORIZATION, "Bearer " + userToken)
@@ -186,23 +198,36 @@ import reactor.core.publisher.Mono;
.valueEquals(HEADER_USER_EMAIL, "test@example.com").expectHeader() .valueEquals(HEADER_USER_EMAIL, "test@example.com").expectHeader()
.valueEquals(HEADER_USER_GROUPS, "groupA").expectBody().isEmpty(); .valueEquals(HEADER_USER_GROUPS, "groupA").expectBody().isEmpty();
} }
@Test @Test
void forwardAuth_PermissionGranted_PathWith_uuid_ReturnsOkWithUserHeaders() { void forwardAuth_PermissionGranted_SpecificRoleMatch() {
String userToken = "user-token-uuid"; String userToken = "user-token-granted";
String originalMethod = "PUT"; String originalMethod = "GET";
String originalUri = "/serviceA/api/items/123e4567-e89b-12d3-a456-426614174000"; // Will be normalized to /api/items/BY_ID
String originalUri = "/serviceA/api/items/123e4567-e89b-12d3-a456-426614174000";
when(this.identityManagerService.verifyToken(userToken)) when(this.identityManagerService.verifyToken(userToken))
.thenReturn(Mono.just(this.mockUserVerificationResponse)); .thenReturn(Mono.just(this.mockUserVerificationResponse));
when(this.identityManagerService.checkUserClientRolePermission("user-sub-123", // Expect checkUserHasAnyClientRole to be called with a list of candidates
"serviceA-client", "PUT_API_ITEMS_BY_ID")).thenReturn(Mono.just(true)); // 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));
this.webTestClient.post().uri("/auth") this.webTestClient.post().uri("/auth")
.header(HttpHeaders.AUTHORIZATION, "Bearer " + userToken) .header(HttpHeaders.AUTHORIZATION, "Bearer " + userToken)
.header(HEADER_X_FORWARDED_METHOD, originalMethod) .header(HEADER_X_FORWARDED_METHOD, originalMethod)
.header(HEADER_X_FORWARDED_URI, originalUri).exchange().expectStatus().isOk() .header(HEADER_X_FORWARDED_URI, originalUri).exchange().expectStatus().isOk()
.expectHeader().valueEquals(HEADER_USER_ID, "user-sub-123").expectBody().isEmpty(); .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 @Test
@@ -215,8 +240,9 @@ import reactor.core.publisher.Mono;
when(this.identityManagerService.verifyToken(validToken)) when(this.identityManagerService.verifyToken(validToken))
.thenReturn(Mono.just(responseWithoutGroups)); .thenReturn(Mono.just(responseWithoutGroups));
when(this.identityManagerService.checkUserClientRolePermission("user-sub-123", when(this.identityManagerService.checkUserHasAnyClientRole(eq("user-sub-123"),
"serviceA-client", "GET_API_NOGROUPS")).thenReturn(Mono.just(true)); eq("serviceA-client"), this.candidateRolesCaptor.capture()))
.thenReturn(Mono.just(true));
this.webTestClient.post().uri("/auth") this.webTestClient.post().uri("/auth")
.header(HttpHeaders.AUTHORIZATION, "Bearer " + validToken) .header(HttpHeaders.AUTHORIZATION, "Bearer " + validToken)
@@ -227,17 +253,48 @@ import reactor.core.publisher.Mono;
.valueEquals(HEADER_USER_EMAIL, "test@example.com").expectHeader() .valueEquals(HEADER_USER_EMAIL, "test@example.com").expectHeader()
.doesNotExist(HEADER_USER_GROUPS).expectBody().isEmpty(); .doesNotExist(HEADER_USER_GROUPS).expectBody().isEmpty();
} }
@Test @Test
void forwardAuth_PermissionDenied_ReturnsForbidden() { void forwardAuth_PermissionGranted_WildcardRoleMatch_All_Star() {
String userToken = "user-token-denied"; String userToken = "user-token-admin";
String originalMethod = "POST"; String originalMethod = "DELETE";
String originalUri = "/serviceA/api/admin"; String originalUri = "/serviceA/admin/config"; // -> /admin/config
when(this.identityManagerService.verifyToken(userToken)) when(this.identityManagerService.verifyToken(userToken))
.thenReturn(Mono.just(this.mockUserVerificationResponse)); .thenReturn(Mono.just(this.mockUserVerificationResponse));
when(this.identityManagerService.checkUserClientRolePermission("user-sub-123", // User has ALL_STAR, so this should pass
"serviceA-client", "POST_API_ADMIN")).thenReturn(Mono.just(false)); 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() {
String userToken = "user-token-denied";
String originalMethod = "POST";
String originalUri = "/serviceA/restricted/action"; // -> /restricted/action
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
this.webTestClient.post().uri("/auth") this.webTestClient.post().uri("/auth")
.header(HttpHeaders.AUTHORIZATION, "Bearer " + userToken) .header(HttpHeaders.AUTHORIZATION, "Bearer " + userToken)
@@ -246,6 +303,27 @@ import reactor.core.publisher.Mono;
.expectBody().isEmpty(); .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 @Test
void forwardAuth_TokenInvalid_ReturnsUnauthorized() { void forwardAuth_TokenInvalid_ReturnsUnauthorized() {
String invalidUserToken = "invalid-user-token"; String invalidUserToken = "invalid-user-token";
@@ -299,25 +377,6 @@ import reactor.core.publisher.Mono;
.isForbidden(); // Controller logic for no rule match .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 @Test
void forwardAuth_MissingAuthHeader_ReturnsBadRequest() { void forwardAuth_MissingAuthHeader_ReturnsBadRequest() {
// This tests if @RequestHeader(HttpHeaders.AUTHORIZATION) is enforced // This tests if @RequestHeader(HttpHeaders.AUTHORIZATION) is enforced
@@ -387,4 +446,9 @@ import reactor.core.publisher.Mono;
this.webTestClient.post().uri("/logout").contentType(MediaType.APPLICATION_JSON) this.webTestClient.post().uri("/logout").contentType(MediaType.APPLICATION_JSON)
.bodyValue(logoutRequest).exchange().expectStatus().isBadRequest(); // Expect 400 .bodyValue(logoutRequest).exchange().expectStatus().isBadRequest(); // Expect 400
} }
// Helper to match any List<String> in Mockito
private static List<String> anyStringList() {
return Mockito.anyList();
}
} }