fix: fix permission check rule resolving
Unit test coverage / gradle-test (push) Successful in 3m30s
CI for publishing docker image / build-and-publish (push) Successful in 3m46s
Unit test coverage / gradle-test (pull_request) Successful in 2m16s

This commit fix the way for resolving the permission check rule. Now it respect the request path

instead of randomly picking one.
This commit is contained in:
2025-07-24 16:30:09 +08:00
parent ff0dcbda5e
commit 973a185f13
5 changed files with 110 additions and 98 deletions
@@ -187,17 +187,19 @@ public class AuthController {
log.info("Received forwardAuth request: Method='{}', Uri='{}'", originalMethod,
originalUriString);
// first detect the client id and service relative path
final var targetServiceClientIdOpt = getTargetServiceClientId(originalUriString);
if (targetServiceClientIdOpt.isEmpty()) {
final var optionalRule = getPermissionCheckRuleByUri(originalUriString);
if (optionalRule.isEmpty()) {
log.warn("ForwardAuth: No Keycloak client mapping found for URI: {}",
originalUriString);
return Mono.just(ResponseEntity.status(HttpStatus.FORBIDDEN).build());
}
final var targetServiceClientId = targetServiceClientIdOpt.get();
final var rule = optionalRule.get();
final var targetServiceClientId = rule.getKeycloakClientId();
final var serviceRelativePath =
extractServiceRelativePath(originalUriString, targetServiceClientId);
extractServiceRelativePath(originalUriString, rule);
// then check pass-through rule
if (shouldPassThrough(targetServiceClientId, serviceRelativePath)) {
if (shouldPassThrough(rule, serviceRelativePath)) {
return Mono.just(ResponseEntity.status(HttpStatus.NO_CONTENT).build());
}
if (authorizationHeader == null) {
@@ -276,16 +278,15 @@ public class AuthController {
}
private boolean shouldPassThrough(
final String serviceClientId, final String serviceRelativePath
final PermissionMappingConfigProperties.Rule rule, final String serviceRelativePath
) {
final var rules = this.permissionMapLookupService
.getPassThoughRulesByClientId(serviceClientId);
final var passthroughs = rule.getPassthroughs();
try {
return rules.stream()
return passthroughs.stream()
.anyMatch(it -> executePassThroughRule(it, serviceRelativePath));
} catch (final Throwable throwable) {
log.error("Error executing pass-through rule for client {} : {}",
serviceClientId, throwable.getMessage(), throwable);
rule.getKeycloakClientId(), throwable.getMessage(), throwable);
return false;
}
}
@@ -300,13 +301,15 @@ public class AuthController {
};
}
private Optional<String> getTargetServiceClientId(String fullUriString) {
private Optional<PermissionMappingConfigProperties.Rule> getPermissionCheckRuleByUri(
String fullUriString
) {
try {
URI uri = new URI(fullUriString);
String path = uri.getPath(); // Get path part, e.g., /cleverswarm/api/jobs/123
log.debug("Mapping URI path: {}", path);
final var pathMatch = this.permissionMapLookupService.matchClientIdByPath(path);
final var pathMatch = this.permissionMapLookupService.matchRuleByPath(path);
if (pathMatch.isPresent()) {
return pathMatch;
}
@@ -315,7 +318,9 @@ public class AuthController {
log.debug(
"No rule matched for path '{}', using default Keycloak Client ID:{}",
path, defaultValue);
return Optional.of(defaultValue);
return Optional.of(PermissionMappingConfigProperties.Rule.builder()
.keycloakClientId(defaultValue)
.build());
}
} catch (URISyntaxException e) {
log.error("Invalid URI syntax for X-Forwarded-Uri: {}", fullUriString, e);
@@ -324,17 +329,11 @@ public class AuthController {
return Optional.empty();
}
private String extractServiceRelativePath(String fullUriString, String mappedKeycloakClientId) {
// Find the rule that gave us this mappedKeycloakClientId to get its pathPrefix
Optional<PermissionMappingConfigProperties.Rule> matchedRule =
this.permissionMapLookupService.matchRuleByClientIdAndPath(
mappedKeycloakClientId, fullUriString
);
String pathPrefixToRemove = "";
if (matchedRule.isPresent()) {
pathPrefixToRemove = matchedRule.get().getPathPrefix();
} else if (mappedKeycloakClientId
private String extractServiceRelativePath(
String fullUriString, PermissionMappingConfigProperties.Rule rule
) {
String pathPrefixToRemove = rule.getPathPrefix();
if (rule.getKeycloakClientId()
.equals(this.permissionMapLookupService.getDefaultKeycloakClientId())) {
// If it's a default mapping, there's no prefix to remove, use the whole path.
// Or, you might decide that default mappings always apply to root paths. This
@@ -1,7 +1,6 @@
package com.cleverthis.authservice.service;
import com.cleverthis.authservice.config.PermissionMappingConfigProperties;
import java.util.List;
import java.util.Optional;
/**
@@ -17,18 +16,5 @@ public interface PermissionMapLookupService {
/**
* Given the path, find the client id of the first rule matching the path prefix.
*/
Optional<String> matchClientIdByPath(String path);
/**
* Given the client id and path, find the rule that both matches the client id exactly,
* and match the path prefix.
*/
Optional<PermissionMappingConfigProperties.Rule> matchRuleByClientIdAndPath(
String clientId, String path);
/**
* Return the list of pass-through rules by client id.
*/
List<PermissionMappingConfigProperties.PassThrough> getPassThoughRulesByClientId(
String clientId);
Optional<PermissionMappingConfigProperties.Rule> matchRuleByPath(String path);
}
@@ -5,7 +5,6 @@ import com.cleverthis.authservice.service.PermissionMapLookupService;
import com.ecwid.consul.v1.ConsulClient;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicReference;
import lombok.extern.slf4j.Slf4j;
@@ -102,38 +101,15 @@ public final class FallbackPermissionMapLookupService implements PermissionMapLo
}
@Override
public Optional<String> matchClientIdByPath(final String path) {
public Optional<PermissionMappingConfigProperties.Rule> matchRuleByPath(final String path) {
final var config = getConfig();
for (final var rule : config.getRules()) {
if (path.startsWith(rule.getPathPrefix())) {
log.debug("Path '{}' matched prefix '{}', using Keycloak Client ID: {}",
path, rule.getPathPrefix(), rule.getKeycloakClientId());
return Optional.of(rule.getKeycloakClientId());
return Optional.of(rule);
}
}
return Optional.empty();
}
@Override
public Optional<PermissionMappingConfigProperties.Rule> matchRuleByClientIdAndPath(
final String clientId, final String path
) {
final var config = getConfig();
return config.getRules().stream()
.filter(rule ->
rule.getKeycloakClientId().equals(clientId)
&& path.startsWith(rule.getPathPrefix()))
.findFirst();
}
@Override
public List<PermissionMappingConfigProperties.PassThrough> getPassThoughRulesByClientId(
final String clientId
) {
final var config = getConfig();
return config.getRules().stream()
.filter(rule -> rule.getKeycloakClientId().equals(clientId))
.flatMap(rule -> rule.getPassthroughs().stream())
.toList();
}
}
@@ -55,7 +55,8 @@ class FallbackPermissionMapLookupServiceTest {
assertNull(service.getConfig()); // fallback is null
// has json content
when(value.getDecodedValue())
.thenReturn(this.objectMapper.writeValueAsString(new PermissionMappingConfigProperties()));
.thenReturn(
this.objectMapper.writeValueAsString(new PermissionMappingConfigProperties()));
service.refreshConsul();
assertEquals(new PermissionMappingConfigProperties(), service.getConfig());
}
@@ -76,45 +77,22 @@ class FallbackPermissionMapLookupServiceTest {
}
@Test
void testMatchClientIdByPath() {
void testMatchRuleByPath() {
final var defaultConfig = new PermissionMappingConfigProperties();
defaultConfig.setRules(List.of(
PermissionMappingConfigProperties.Rule.builder()
.keycloakClientId("service-A").pathPrefix("/a/").build(),
PermissionMappingConfigProperties.Rule.builder()
.keycloakClientId("service-B").pathPrefix("/b/").build()
));
final var ruleA = PermissionMappingConfigProperties.Rule.builder()
.keycloakClientId("service-A").pathPrefix("/a/").build();
final var ruleB = PermissionMappingConfigProperties.Rule.builder()
.keycloakClientId("service-B").pathPrefix("/b/").build();
defaultConfig.setRules(List.of(ruleA, ruleB));
final var service = new FallbackPermissionMapLookupService(
defaultConfig, this.consulClient, this.objectMapper);
assertEquals(
"service-B",
service.matchClientIdByPath("/b/items/123").orElseThrow());
ruleB,
service.matchRuleByPath("/b/items/123").orElseThrow());
assertEquals(
Optional.empty(),
service.matchClientIdByPath("/c/items/123"));
service.matchRuleByPath("/c/items/123"));
}
@Test
void testMatchRuleByClientIdAndPath() {
final var defaultConfig = new PermissionMappingConfigProperties();
defaultConfig.setRules(List.of(
PermissionMappingConfigProperties.Rule.builder()
.keycloakClientId("service-A").pathPrefix("/a/").build(),
PermissionMappingConfigProperties.Rule.builder()
.keycloakClientId("service-B").pathPrefix("/b/").build()
));
final var service = new FallbackPermissionMapLookupService(
defaultConfig, this.consulClient, this.objectMapper);
service.matchRuleByClientIdAndPath("service-A", "/a/items/123")
.ifPresentOrElse(
rule -> assertEquals("service-A", rule.getKeycloakClientId()),
() -> Assertions.fail("No rule matched"));
service.matchRuleByClientIdAndPath("service-B", "/a/items/123")
.ifPresentOrElse(
it -> Assertions.fail("Rule matched but should not have"),
() -> {}
);
}
}
@@ -28,6 +28,7 @@ import org.keycloak.representations.idm.GroupRepresentation;
import org.keycloak.representations.idm.MemberRepresentation;
import org.keycloak.representations.idm.OrganizationDomainRepresentation;
import org.keycloak.representations.idm.OrganizationRepresentation;
import org.keycloak.representations.idm.UserRepresentation;
import org.mockito.Mockito;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;
@@ -558,4 +559,76 @@ class KeycloakIdentityManagerServiceTest {
Mockito.verify(this.adminClient).getGroupById(groupId);
}
@Test
void listSubGroups_success() {
// Arrange
final var parentGroupId = "parent-group-id";
final var subGroups = List.of(
new GroupRepresentation() {{
setId("sub-group-1");
setName("SubGroup1");
}},
new GroupRepresentation() {{
setId("sub-group-2");
setName("SubGroup2");
}}
);
Mockito.when(this.adminClient.listSubGroups(parentGroupId))
.thenReturn(Mono.just(subGroups));
// Act
final var result = this.service.listSubGroups(parentGroupId);
// Assert
StepVerifier.create(result)
.expectNextMatches(groups -> groups.size() == 2 &&
"SubGroup1".equals(groups.getFirst().getName()) &&
"sub-group-2".equals(groups.get(1).getId()))
.verifyComplete();
Mockito.verify(this.adminClient).listSubGroups(parentGroupId);
}
@Test
void getGroupMembers_success() {
// Arrange
final var groupId = "test-group-id";
final var mockMembers = List.of(
new UserRepresentation() {{
setId("user1");
setUsername("user1Username");
setFirstName("User1");
setLastName("One");
setEmail("user1@example.com");
setEnabled(true);
}},
new UserRepresentation() {{
setId("user2");
setUsername("user2Username");
setFirstName("User2");
setLastName("Two");
setEmail("user2@example.com");
setEnabled(true);
}}
);
Mockito.when(this.adminClient.getGroupMembers(groupId))
.thenReturn(Mono.just(mockMembers));
// Act
final var result = this.service.getGroupMembers(groupId);
// Assert
StepVerifier.create(result)
.expectNextMatches(members -> members.size() == 2
&& "user1".equals(members.getFirst().getId())
&& "user1@example.com".equals(members.getFirst().getEmail())
&& "user2".equals(members.get(1).getId())
&& "user2@example.com".equals(members.get(1).getEmail()))
.verifyComplete();
Mockito.verify(this.adminClient).getGroupMembers(groupId);
}
}