feat#3_implement_access_control #22
@@ -10,7 +10,12 @@ import com.cleverthis.authservice.service.IdentityManagerService;
|
||||
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;
|
||||
@@ -207,20 +212,25 @@ public class AuthController {
|
||||
// Path for role construction should be relative to the service prefix
|
||||
String serviceRelativePath =
|
||||
extractServiceRelativePath(originalUriString, targetServiceClientId);
|
||||
String requiredRoleName =
|
||||
constructRequiredRoleName(originalMethod, serviceRelativePath);
|
||||
List<String> candidateRoleNames =
|
||||
generateCandidateRoleNames(originalMethod, serviceRelativePath);
|
||||
|
||||
log.debug("ForwardAuth: UserSub='{}', TargetClient='{}', RequiredRole='{}'",
|
||||
userSub, targetServiceClientId, requiredRoleName);
|
||||
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());
|
||||
}
|
||||
|
||||
// Step 4: Check permission
|
||||
return this.identityManagerService.checkUserClientRolePermission(userSub,
|
||||
targetServiceClientId, requiredRoleName).flatMap(hasPermission -> {
|
||||
return this.identityManagerService.checkUserHasAnyClientRole(userSub,
|
||||
targetServiceClientId, candidateRoleNames).flatMap(hasPermission -> {
|
||||
if (hasPermission) {
|
||||
log.info(
|
||||
"ForwardAuth: GRANTED for UserSub='{}',"
|
||||
+ " Role='{}', Client='{}'",
|
||||
userSub, requiredRoleName, targetServiceClientId);
|
||||
userSub, candidateRoleNames, targetServiceClientId);
|
||||
HttpHeaders responseHeaders =
|
||||
buildAuthHeaders(verificationResponse);
|
||||
return Mono.just(ResponseEntity.ok().headers(responseHeaders)
|
||||
@@ -229,7 +239,7 @@ public class AuthController {
|
||||
log.warn(
|
||||
"ForwardAuth: DENIED for UserSub='{}',"
|
||||
+ " Role='{}', Client='{}'",
|
||||
userSub, requiredRoleName, targetServiceClientId);
|
||||
userSub, candidateRoleNames, targetServiceClientId);
|
||||
return Mono.just(ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.<Void>build());
|
||||
}
|
||||
@@ -325,37 +335,94 @@ public class AuthController {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 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
|
||||
*/
|
||||
private String constructRequiredRoleName(String httpMethod, String relativePath) {
|
||||
// Normalize path: remove leading/trailing slashes, replace slashes with underscores
|
||||
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
|
||||
String normalizedPath = relativePath.trim();
|
||||
if (normalizedPath.startsWith("/")) {
|
||||
normalizedPath = normalizedPath.substring(1);
|
||||
}
|
||||
if (normalizedPath.endsWith("/")) {
|
||||
normalizedPath = normalizedPath.substring(0, normalizedPath.length() - 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
|
||||
}
|
||||
}
|
||||
|
||||
// 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(
|
||||
// Replace path parameters and UUIDs
|
||||
String processedPath = normalizedPath.replaceAll("\\{[^}]+}", "BY_ID");
|
||||
processedPath = processedPath.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");
|
||||
|
||||
// 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
|
||||
List<String> segments = new ArrayList<>();
|
||||
if (!processedPath.isEmpty()) {
|
||||
segments.addAll(Arrays.asList(processedPath.split("/")));
|
||||
}
|
||||
|
||||
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;
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
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
|
||||
|
||||
/**
|
||||
@@ -46,12 +48,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 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
|
||||
* check cannot be performed (e.g., Keycloak unavailable).
|
||||
*/
|
||||
Mono<Boolean> checkUserClientRolePermission(String userSub, String targetServiceClientId,
|
||||
String requiredRoleName);
|
||||
Mono<Boolean> checkUserHasAnyClientRole(String userSub, String targetServiceClientId,
|
||||
List<String> candidateRoleNames);
|
||||
|
||||
/**
|
||||
* Registers a new user in the identity provider.
|
||||
|
||||
+11
-8
@@ -12,6 +12,7 @@ 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;
|
||||
@@ -177,10 +178,10 @@ public class KeycloakIdentityManagerService implements IdentityManagerService {
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Boolean> checkUserClientRolePermission(String userSub, String targetServiceClientId,
|
||||
String requiredRoleName) {
|
||||
public Mono<Boolean> checkUserHasAnyClientRole(String userSub, String targetServiceClientId,
|
||||
List<String> candidateRoleNames) {
|
||||
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
|
||||
return this.keycloakAdminClient.getClientInternalId(targetServiceClientId)
|
||||
@@ -193,15 +194,17 @@ 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 -> requiredRoleName.equals(role.getName()));
|
||||
.anyMatch(role -> candidateRoleNames.contains(role.getName()));
|
||||
if (hasRole) {
|
||||
log.info("Permission GRANTED for userSub '{}' to role '{}' on client '{}'",
|
||||
userSub, requiredRoleName, targetServiceClientId);
|
||||
log.info("Permission GRANTED for userSub '{}' to role '{}' on client '{}'"
|
||||
+ ". User roles: {}",
|
||||
userSub, candidateRoleNames, targetServiceClientId,
|
||||
roles.stream().map(KeycloakRoleRepresentation::getName).toList());
|
||||
} else {
|
||||
log.warn(
|
||||
"Permission DENIED for userSub '{}' to role '{}' on"
|
||||
+ " client '{}'. User roles: {}",
|
||||
userSub, requiredRoleName, targetServiceClientId,
|
||||
userSub, candidateRoleNames, targetServiceClientId,
|
||||
roles.stream().map(KeycloakRoleRepresentation::getName).toList());
|
||||
}
|
||||
return hasRole;
|
||||
@@ -209,7 +212,7 @@ public class KeycloakIdentityManagerService implements IdentityManagerService {
|
||||
.doOnError(e -> log.error(
|
||||
"Error during permission check for userSub '{}', "
|
||||
+ "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
|
||||
// fail), treat as permission denied.
|
||||
// Or, you could throw a specific PermissionCheckException to be handled by
|
||||
|
||||
@@ -10,7 +10,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: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!)
|
||||
# Grant type specific settings (ROPC for /login)
|
||||
grant-type: password
|
||||
@@ -31,7 +31,7 @@ auth-service:
|
||||
- pathPrefix: "/cleverbrag/"
|
||||
keycloakClientId: "cleverbrag-client"
|
||||
- pathPrefix: "/test-service/"
|
||||
keycloakClientId: "test-client"
|
||||
keycloakClientId: "auth-service"
|
||||
# Add more rules as needed
|
||||
# Default Keycloak Client ID if no prefix matches (optional)
|
||||
# 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.hasItems;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
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.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 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; // Import annotation
|
||||
@@ -43,6 +49,9 @@ import reactor.core.publisher.Mono;
|
||||
@MockitoBean
|
||||
private PermissionMappingConfig permissionMappingConfig;
|
||||
|
||||
@Captor
|
||||
private ArgumentCaptor<List<String>> candidateRolesCaptor;
|
||||
|
||||
private TokenResponse mockTokenResponse;
|
||||
private VerificationResponse mockUserVerificationResponse;
|
||||
|
||||
@@ -166,6 +175,8 @@ import reactor.core.publisher.Mono;
|
||||
|
||||
// --- /auth (ForwardAuth) Tests ---
|
||||
|
||||
// --- NEW /auth (ForwardAuth) Tests with Candidate Role List Logic ---
|
||||
|
||||
@Test
|
||||
void forwardAuth_PermissionGranted_ReturnsOkWithUserHeaders() {
|
||||
String userToken = "user-token-granted";
|
||||
@@ -174,8 +185,9 @@ import reactor.core.publisher.Mono;
|
||||
|
||||
when(this.identityManagerService.verifyToken(userToken))
|
||||
.thenReturn(Mono.just(this.mockUserVerificationResponse));
|
||||
when(this.identityManagerService.checkUserClientRolePermission("user-sub-123",
|
||||
"serviceA-client", "GET_API_DATA")).thenReturn(Mono.just(true));
|
||||
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)
|
||||
@@ -186,23 +198,36 @@ import reactor.core.publisher.Mono;
|
||||
.valueEquals(HEADER_USER_EMAIL, "test@example.com").expectHeader()
|
||||
.valueEquals(HEADER_USER_GROUPS, "groupA").expectBody().isEmpty();
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
void forwardAuth_PermissionGranted_PathWith_uuid_ReturnsOkWithUserHeaders() {
|
||||
String userToken = "user-token-uuid";
|
||||
String originalMethod = "PUT";
|
||||
String originalUri = "/serviceA/api/items/123e4567-e89b-12d3-a456-426614174000";
|
||||
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";
|
||||
|
||||
when(this.identityManagerService.verifyToken(userToken))
|
||||
.thenReturn(Mono.just(this.mockUserVerificationResponse));
|
||||
when(this.identityManagerService.checkUserClientRolePermission("user-sub-123",
|
||||
"serviceA-client", "PUT_API_ITEMS_BY_ID")).thenReturn(Mono.just(true));
|
||||
// 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));
|
||||
|
||||
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
|
||||
@@ -215,8 +240,9 @@ import reactor.core.publisher.Mono;
|
||||
|
||||
when(this.identityManagerService.verifyToken(validToken))
|
||||
.thenReturn(Mono.just(responseWithoutGroups));
|
||||
when(this.identityManagerService.checkUserClientRolePermission("user-sub-123",
|
||||
"serviceA-client", "GET_API_NOGROUPS")).thenReturn(Mono.just(true));
|
||||
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 " + validToken)
|
||||
@@ -227,17 +253,48 @@ import reactor.core.publisher.Mono;
|
||||
.valueEquals(HEADER_USER_EMAIL, "test@example.com").expectHeader()
|
||||
.doesNotExist(HEADER_USER_GROUPS).expectBody().isEmpty();
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
void forwardAuth_PermissionDenied_ReturnsForbidden() {
|
||||
String userToken = "user-token-denied";
|
||||
String originalMethod = "POST";
|
||||
String originalUri = "/serviceA/api/admin";
|
||||
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));
|
||||
when(this.identityManagerService.checkUserClientRolePermission("user-sub-123",
|
||||
"serviceA-client", "POST_API_ADMIN")).thenReturn(Mono.just(false));
|
||||
// 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() {
|
||||
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")
|
||||
.header(HttpHeaders.AUTHORIZATION, "Bearer " + userToken)
|
||||
@@ -246,6 +303,27 @@ import reactor.core.publisher.Mono;
|
||||
.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";
|
||||
@@ -299,25 +377,6 @@ import reactor.core.publisher.Mono;
|
||||
.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
|
||||
@@ -387,4 +446,9 @@ import reactor.core.publisher.Mono;
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user