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, log.info("Received forwardAuth request: Method='{}', Uri='{}'", originalMethod,
originalUriString); originalUriString);
// first detect the client id and service relative path // first detect the client id and service relative path
final var targetServiceClientIdOpt = getTargetServiceClientId(originalUriString); final var optionalRule = getPermissionCheckRuleByUri(originalUriString);
if (targetServiceClientIdOpt.isEmpty()) { if (optionalRule.isEmpty()) {
log.warn("ForwardAuth: No Keycloak client mapping found for URI: {}", log.warn("ForwardAuth: No Keycloak client mapping found for URI: {}",
originalUriString); originalUriString);
return Mono.just(ResponseEntity.status(HttpStatus.FORBIDDEN).build()); 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 = final var serviceRelativePath =
extractServiceRelativePath(originalUriString, targetServiceClientId); extractServiceRelativePath(originalUriString, rule);
// then check pass-through rule // then check pass-through rule
if (shouldPassThrough(targetServiceClientId, serviceRelativePath)) { if (shouldPassThrough(rule, serviceRelativePath)) {
return Mono.just(ResponseEntity.status(HttpStatus.NO_CONTENT).build()); return Mono.just(ResponseEntity.status(HttpStatus.NO_CONTENT).build());
} }
if (authorizationHeader == null) { if (authorizationHeader == null) {
@@ -276,16 +278,15 @@ public class AuthController {
} }
private boolean shouldPassThrough( private boolean shouldPassThrough(
final String serviceClientId, final String serviceRelativePath final PermissionMappingConfigProperties.Rule rule, final String serviceRelativePath
) { ) {
final var rules = this.permissionMapLookupService final var passthroughs = rule.getPassthroughs();
.getPassThoughRulesByClientId(serviceClientId);
try { try {
return rules.stream() return passthroughs.stream()
.anyMatch(it -> executePassThroughRule(it, serviceRelativePath)); .anyMatch(it -> executePassThroughRule(it, serviceRelativePath));
} catch (final Throwable throwable) { } catch (final Throwable throwable) {
log.error("Error executing pass-through rule for client {} : {}", log.error("Error executing pass-through rule for client {} : {}",
serviceClientId, throwable.getMessage(), throwable); rule.getKeycloakClientId(), throwable.getMessage(), throwable);
return false; return false;
} }
} }
@@ -300,13 +301,15 @@ public class AuthController {
}; };
} }
private Optional<String> getTargetServiceClientId(String fullUriString) { private Optional<PermissionMappingConfigProperties.Rule> getPermissionCheckRuleByUri(
String fullUriString
) {
try { try {
URI uri = new URI(fullUriString); URI uri = new URI(fullUriString);
String path = uri.getPath(); // Get path part, e.g., /cleverswarm/api/jobs/123 String path = uri.getPath(); // Get path part, e.g., /cleverswarm/api/jobs/123
log.debug("Mapping URI path: {}", path); log.debug("Mapping URI path: {}", path);
final var pathMatch = this.permissionMapLookupService.matchClientIdByPath(path); final var pathMatch = this.permissionMapLookupService.matchRuleByPath(path);
if (pathMatch.isPresent()) { if (pathMatch.isPresent()) {
return pathMatch; return pathMatch;
} }
@@ -315,7 +318,9 @@ public class AuthController {
log.debug( log.debug(
"No rule matched for path '{}', using default Keycloak Client ID:{}", "No rule matched for path '{}', using default Keycloak Client ID:{}",
path, defaultValue); path, defaultValue);
return Optional.of(defaultValue); return Optional.of(PermissionMappingConfigProperties.Rule.builder()
.keycloakClientId(defaultValue)
.build());
} }
} catch (URISyntaxException e) { } catch (URISyntaxException e) {
log.error("Invalid URI syntax for X-Forwarded-Uri: {}", fullUriString, e); log.error("Invalid URI syntax for X-Forwarded-Uri: {}", fullUriString, e);
@@ -324,17 +329,11 @@ public class AuthController {
return Optional.empty(); return Optional.empty();
} }
private String extractServiceRelativePath(String fullUriString, String mappedKeycloakClientId) { private String extractServiceRelativePath(
// Find the rule that gave us this mappedKeycloakClientId to get its pathPrefix String fullUriString, PermissionMappingConfigProperties.Rule rule
Optional<PermissionMappingConfigProperties.Rule> matchedRule = ) {
this.permissionMapLookupService.matchRuleByClientIdAndPath( String pathPrefixToRemove = rule.getPathPrefix();
mappedKeycloakClientId, fullUriString if (rule.getKeycloakClientId()
);
String pathPrefixToRemove = "";
if (matchedRule.isPresent()) {
pathPrefixToRemove = matchedRule.get().getPathPrefix();
} else if (mappedKeycloakClientId
.equals(this.permissionMapLookupService.getDefaultKeycloakClientId())) { .equals(this.permissionMapLookupService.getDefaultKeycloakClientId())) {
// If it's a default mapping, there's no prefix to remove, use the whole path. // 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 // Or, you might decide that default mappings always apply to root paths. This
@@ -1,7 +1,6 @@
package com.cleverthis.authservice.service; package com.cleverthis.authservice.service;
import com.cleverthis.authservice.config.PermissionMappingConfigProperties; import com.cleverthis.authservice.config.PermissionMappingConfigProperties;
import java.util.List;
import java.util.Optional; 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. * Given the path, find the client id of the first rule matching the path prefix.
*/ */
Optional<String> matchClientIdByPath(String path); Optional<PermissionMappingConfigProperties.Rule> matchRuleByPath(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);
} }
@@ -5,7 +5,6 @@ import com.cleverthis.authservice.service.PermissionMapLookupService;
import com.ecwid.consul.v1.ConsulClient; import com.ecwid.consul.v1.ConsulClient;
import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.atomic.AtomicReference;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@@ -102,38 +101,15 @@ public final class FallbackPermissionMapLookupService implements PermissionMapLo
} }
@Override @Override
public Optional<String> matchClientIdByPath(final String path) { public Optional<PermissionMappingConfigProperties.Rule> matchRuleByPath(final String path) {
final var config = getConfig(); final var config = getConfig();
for (final var rule : config.getRules()) { for (final var rule : config.getRules()) {
if (path.startsWith(rule.getPathPrefix())) { if (path.startsWith(rule.getPathPrefix())) {
log.debug("Path '{}' matched prefix '{}', using Keycloak Client ID: {}", log.debug("Path '{}' matched prefix '{}', using Keycloak Client ID: {}",
path, rule.getPathPrefix(), rule.getKeycloakClientId()); path, rule.getPathPrefix(), rule.getKeycloakClientId());
return Optional.of(rule.getKeycloakClientId()); return Optional.of(rule);
} }
} }
return Optional.empty(); 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 assertNull(service.getConfig()); // fallback is null
// has json content // has json content
when(value.getDecodedValue()) when(value.getDecodedValue())
.thenReturn(this.objectMapper.writeValueAsString(new PermissionMappingConfigProperties())); .thenReturn(
this.objectMapper.writeValueAsString(new PermissionMappingConfigProperties()));
service.refreshConsul(); service.refreshConsul();
assertEquals(new PermissionMappingConfigProperties(), service.getConfig()); assertEquals(new PermissionMappingConfigProperties(), service.getConfig());
} }
@@ -76,45 +77,22 @@ class FallbackPermissionMapLookupServiceTest {
} }
@Test @Test
void testMatchClientIdByPath() { void testMatchRuleByPath() {
final var defaultConfig = new PermissionMappingConfigProperties(); final var defaultConfig = new PermissionMappingConfigProperties();
defaultConfig.setRules(List.of( final var ruleA = PermissionMappingConfigProperties.Rule.builder()
PermissionMappingConfigProperties.Rule.builder() .keycloakClientId("service-A").pathPrefix("/a/").build();
.keycloakClientId("service-A").pathPrefix("/a/").build(), final var ruleB = PermissionMappingConfigProperties.Rule.builder()
PermissionMappingConfigProperties.Rule.builder() .keycloakClientId("service-B").pathPrefix("/b/").build();
.keycloakClientId("service-B").pathPrefix("/b/").build() defaultConfig.setRules(List.of(ruleA, ruleB));
));
final var service = new FallbackPermissionMapLookupService( final var service = new FallbackPermissionMapLookupService(
defaultConfig, this.consulClient, this.objectMapper); defaultConfig, this.consulClient, this.objectMapper);
assertEquals( assertEquals(
"service-B", ruleB,
service.matchClientIdByPath("/b/items/123").orElseThrow()); service.matchRuleByPath("/b/items/123").orElseThrow());
assertEquals( assertEquals(
Optional.empty(), 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.MemberRepresentation;
import org.keycloak.representations.idm.OrganizationDomainRepresentation; import org.keycloak.representations.idm.OrganizationDomainRepresentation;
import org.keycloak.representations.idm.OrganizationRepresentation; import org.keycloak.representations.idm.OrganizationRepresentation;
import org.keycloak.representations.idm.UserRepresentation;
import org.mockito.Mockito; import org.mockito.Mockito;
import org.springframework.web.reactive.function.client.WebClient; import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
@@ -558,4 +559,76 @@ class KeycloakIdentityManagerServiceTest {
Mockito.verify(this.adminClient).getGroupById(groupId); 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);
}
} }