Compare commits

..

2 Commits

Author SHA1 Message Date
hurui200320 eaa14e7101 feat(General): implement consul config permission mapping resolving with fallback to application yam
Unit test coverage / gradle-test (pull_request) Failing after 2m36s
Unit test coverage / gradle-test (push) Failing after 1m40s
CI for publishing docker image / build-and-publish (push) Successful in 2m20s
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-13 12:17:06 +08:00
abed.alrahman b764c6f12b feat#3_implement_access_control (#22)
Unit test coverage / gradle-test (push) Failing after 1m44s
CI for publishing docker image / build-and-publish (push) Successful in 2m31s
Co-authored-by: Abed <alrbeei@yahoo.com>
Reviewed-on: #22
Reviewed-by: Rui Hu <rui.hu@cleverthis.com>
Reviewed-by: Stanislav Hejny <stanislav.hejny@cleverthis.com>
Co-authored-by: Abed Alrahman <abed.alrahman@cleverthis.com>
Co-committed-by: Abed Alrahman <abed.alrahman@cleverthis.com>
2025-06-12 11:15:42 +00:00
5 changed files with 213 additions and 78 deletions
@@ -11,7 +11,12 @@ import com.cleverthis.authservice.service.PermissionMapLookupService;
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;
@@ -210,20 +215,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)
@@ -232,7 +242,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
+1 -1
View File
@@ -17,7 +17,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
@@ -1,6 +1,9 @@
package com.cleverthis.authservice; package com.cleverthis.authservice;
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;
@@ -12,7 +15,6 @@ 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 com.cleverthis.authservice.service.impl.FallbackPermissionMapLookupService; import com.cleverthis.authservice.service.impl.FallbackPermissionMapLookupService;
@@ -21,6 +23,9 @@ 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 org.springframework.context.annotation.Import;
@@ -49,6 +54,9 @@ class AuthControllerTest {
@MockitoBean @MockitoBean
private PermissionMappingConfig permissionMappingConfig; private PermissionMappingConfig permissionMappingConfig;
@Captor
private ArgumentCaptor<List<String>> candidateRolesCaptor;
// required by the fallback permission mapping lookup service // required by the fallback permission mapping lookup service
// provide a mocked one // provide a mocked one
@MockitoBean @MockitoBean
@@ -177,6 +185,8 @@ class AuthControllerTest {
// --- /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";
@@ -185,8 +195,9 @@ class AuthControllerTest {
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)
@@ -197,23 +208,36 @@ class AuthControllerTest {
.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
@@ -226,8 +250,9 @@ class AuthControllerTest {
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)
@@ -238,17 +263,48 @@ class AuthControllerTest {
.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)
@@ -257,6 +313,27 @@ class AuthControllerTest {
.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";
@@ -310,25 +387,6 @@ class AuthControllerTest {
.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
@@ -398,4 +456,9 @@ class AuthControllerTest {
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();
}
} }