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 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;
@@ -210,20 +215,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)
@@ -232,7 +242,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("/")) {
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.
@@ -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
+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: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
@@ -1,6 +1,9 @@
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;
@@ -12,7 +15,6 @@ 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;
@@ -21,6 +23,9 @@ 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;
@@ -49,6 +54,9 @@ 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
@@ -177,6 +185,8 @@ class AuthControllerTest {
// --- /auth (ForwardAuth) Tests ---
// --- NEW /auth (ForwardAuth) Tests with Candidate Role List Logic ---
@Test
void forwardAuth_PermissionGranted_ReturnsOkWithUserHeaders() {
String userToken = "user-token-granted";
@@ -185,8 +195,9 @@ class AuthControllerTest {
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)
@@ -199,21 +210,34 @@ class AuthControllerTest {
}
@Test
void forwardAuth_PermissionGranted_PathWith_uuid_ReturnsOkWithUserHeaders() {
String userToken = "user-token-uuid";
String originalMethod = "PUT";
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
@@ -226,8 +250,9 @@ class AuthControllerTest {
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)
@@ -240,15 +265,46 @@ class AuthControllerTest {
}
@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)
@@ -257,6 +313,27 @@ 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";
@@ -310,25 +387,6 @@ 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
@@ -398,4 +456,9 @@ 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();
}
}