This is an automated email from the ASF dual-hosted git repository. acosentino pushed a commit to branch keycloak-perms-new in repository https://gitbox.apache.org/repos/asf/camel.git
commit 53094c739005cc669ac37d0bdb24c4e79218bfd6 Author: Andrea Cosentino <[email protected]> AuthorDate: Mon Sep 29 09:52:50 2025 +0200 CAMEL-22459 - Camel-Keycloak: Support permission based policies Signed-off-by: Andrea Cosentino <[email protected]> --- .../camel-keycloak/src/main/docs/keycloak.adoc | 181 +++++++++++++++++++++ .../keycloak/security/KeycloakSecurityHelper.java | 24 +++ .../security/KeycloakSecurityProcessor.java | 35 ++++ .../security/KeycloakSecurityHelperTest.java | 71 ++++++++ .../keycloak/security/KeycloakSecurityIT.java | 165 +++++++++++++++++++ 5 files changed, 476 insertions(+) diff --git a/components/camel-keycloak/src/main/docs/keycloak.adoc b/components/camel-keycloak/src/main/docs/keycloak.adoc index ce70d21939d..d0342c3a1f5 100644 --- a/components/camel-keycloak/src/main/docs/keycloak.adoc +++ b/components/camel-keycloak/src/main/docs/keycloak.adoc @@ -819,6 +819,16 @@ beans: The component includes integration tests that require a running Keycloak instance. These tests are disabled by default and only run when specific system properties are provided. +The integration tests include comprehensive testing for: +* Role-based authorization with different role requirements +* Permission-based authorization using custom claims and scopes +* Public key verification with JWKS endpoint integration +* Combined roles and permissions validation +* Token parsing with and without public key verification +* Different authorization header formats (Bearer token, custom header) +* Token expiration and validity checks +* Error handling for invalid tokens and insufficient privileges + === Starting Keycloak with Docker ==== 1. Start Keycloak Container @@ -898,6 +908,7 @@ Create three test users with the following configuration: ==== 7. Execute Tests with Maven +**Run All Integration Tests:** [source,bash] ---- # Run integration tests with required properties @@ -908,6 +919,31 @@ mvn test -Dtest=KeycloakSecurityIT \ -Dkeycloak.client.secret=YOUR_CLIENT_SECRET ---- +**Run Specific Test Categories:** +[source,bash] +---- +# Test only role-based authorization +mvn test -Dtest=KeycloakSecurityIT#testKeycloakSecurityPolicyWithValidAdminToken,testKeycloakSecurityPolicyWithValidUserToken,testKeycloakSecurityPolicyUserCannotAccessAdminRoute \ + -Dkeycloak.server.url=http://localhost:8080 \ + -Dkeycloak.realm=test-realm \ + -Dkeycloak.client.id=test-client \ + -Dkeycloak.client.secret=YOUR_CLIENT_SECRET + +# Test only permissions-based authorization +mvn test -Dtest=KeycloakSecurityIT#testKeycloakSecurityPolicyWithPermissions,testKeycloakSecurityPolicyWithScopeBasedPermissions,testKeycloakSecurityPolicyWithCombinedRolesAndPermissions \ + -Dkeycloak.server.url=http://localhost:8080 \ + -Dkeycloak.realm=test-realm \ + -Dkeycloak.client.id=test-client \ + -Dkeycloak.client.secret=YOUR_CLIENT_SECRET + +# Test only public key verification +mvn test -Dtest=KeycloakSecurityIT#testKeycloakSecurityPolicyWithPublicKeyVerification,testParseTokenDirectlyWithPublicKey \ + -Dkeycloak.server.url=http://localhost:8080 \ + -Dkeycloak.realm=test-realm \ + -Dkeycloak.client.id=test-client \ + -Dkeycloak.client.secret=YOUR_CLIENT_SECRET +---- + Replace `YOUR_CLIENT_SECRET` with the actual client secret from step 4. ==== 8. Alternative: Set Environment Variables @@ -939,3 +975,148 @@ mvn test -Dtest=KeycloakSecurityIT \ **Connection refused**: Ensure Keycloak is running and accessible at the specified URL. **Token validation errors**: Verify the realm name and client configuration match exactly. + +=== Setting up Permissions in Keycloak + +For permissions-based authorization, you have several options to include permissions in tokens: + +==== Option 1: Custom Claims Mapper + +1. In your realm, go to **Client Scopes** → **roles** → **Mappers** → **Create mapper** +2. Set the following: + - Mapper Type: `User Attribute` + - Name: `permissions-mapper` + - User Attribute: `permissions` + - Token Claim Name: `permissions` + - Claim JSON Type: `JSON` + - Add to ID token: `ON` + - Add to access token: `ON` + +3. Add the `permissions` attribute to users: + - Go to **Users** → Select user → **Attributes** tab + - Add attribute: `permissions` with value like `["read:documents", "write:documents"]` + +==== Option 2: Scope-based Permissions + +1. Configure client scopes: + - Go to **Client Scopes** → **Create client scope** + - Scope Name: `documents` + - Protocol: `openid-connect` + +2. Add scope to client: + - Go to **Clients** → Your client → **Client Scopes** tab + - Add the scope as **Default** or **Optional** + +3. In your application code, you can then use scopes as permissions: + +[source,java] +---- +KeycloakSecurityPolicy policy = new KeycloakSecurityPolicy(); +policy.setRequiredPermissions(Arrays.asList("documents", "users", "admin")); +policy.setAllPermissionsRequired(false); // ANY permission +---- + +==== Option 3: Authorization Services (Advanced) + +For complex permission models, enable Keycloak Authorization Services: + +1. Go to **Clients** → Your client → **Settings** → **Authorization Enabled**: `ON` +2. Configure Resources, Scopes, and Policies in the **Authorization** tab +3. Enable **Authorization** on the client + +Note: Full Authorization Services integration requires additional setup and is more complex than the simple approaches above. + +=== Combined Roles and Permissions Example + +[tabs] +==== +Java:: ++ +[source,java] +---- +// Create a policy that requires BOTH roles AND permissions +KeycloakSecurityPolicy strictPolicy = new KeycloakSecurityPolicy(); +strictPolicy.setServerUrl("{{keycloak.server-url}}"); +strictPolicy.setRealm("{{keycloak.realm}}"); +strictPolicy.setClientId("{{keycloak.client-id}}"); +strictPolicy.setClientSecret("{{keycloak.client-secret}}"); + +// User must have admin role AND document permissions +strictPolicy.setRequiredRoles(Arrays.asList("admin")); +strictPolicy.setRequiredPermissions(Arrays.asList("read:documents", "write:documents")); +strictPolicy.setAllRolesRequired(true); +strictPolicy.setAllPermissionsRequired(false); // ANY permission + +// Create a policy that requires EITHER roles OR permissions +KeycloakSecurityPolicy flexiblePolicy = new KeycloakSecurityPolicy(); +flexiblePolicy.setServerUrl("{{keycloak.server-url}}"); +flexiblePolicy.setRealm("{{keycloak.realm}}"); +flexiblePolicy.setClientId("{{keycloak.client-id}}"); +flexiblePolicy.setClientSecret("{{keycloak.client-secret}}"); + +// Apply different policies to different routes +from("direct:admin-documents") + .policy(strictPolicy) + .to("bean:documentService?method=adminOperations"); + +from("direct:flexible-access") + .policy(flexiblePolicy) + .to("bean:documentService?method=flexibleOperations"); +---- + +YAML:: ++ +[source,yaml] +---- +- route: + from: + uri: direct:admin-documents + steps: + - policy: + ref: strictPolicy + - to: + uri: bean:documentService?method=adminOperations + +- route: + from: + uri: direct:flexible-access + steps: + - policy: + ref: flexiblePolicy + - to: + uri: bean:documentService?method=flexibleOperations + +# Bean definitions +beans: + - name: strictPolicy + type: org.apache.camel.component.keycloak.security.KeycloakSecurityPolicy + properties: + serverUrl: "{{keycloak.server-url}}" + realm: "{{keycloak.realm}}" + clientId: "{{keycloak.client-id}}" + clientSecret: "{{keycloak.client-secret}}" + requiredRoles: + - "admin" + requiredPermissions: + - "read:documents" + - "write:documents" + allRolesRequired: true + allPermissionsRequired: false + + - name: flexiblePolicy + type: org.apache.camel.component.keycloak.security.KeycloakSecurityPolicy + properties: + serverUrl: "{{keycloak.server-url}}" + realm: "{{keycloak.realm}}" + clientId: "{{keycloak.client-id}}" + clientSecret: "{{keycloak.client-secret}}" + requiredRoles: + - "admin" + - "manager" + requiredPermissions: + - "read:documents" + - "emergency:access" + allRolesRequired: false + allPermissionsRequired: false +---- +==== diff --git a/components/camel-keycloak/src/main/java/org/apache/camel/component/keycloak/security/KeycloakSecurityHelper.java b/components/camel-keycloak/src/main/java/org/apache/camel/component/keycloak/security/KeycloakSecurityHelper.java index c845ada2eca..dfa8c081c90 100644 --- a/components/camel-keycloak/src/main/java/org/apache/camel/component/keycloak/security/KeycloakSecurityHelper.java +++ b/components/camel-keycloak/src/main/java/org/apache/camel/component/keycloak/security/KeycloakSecurityHelper.java @@ -17,6 +17,7 @@ package org.apache.camel.component.keycloak.security; import java.security.PublicKey; +import java.util.Collection; import java.util.HashSet; import java.util.Map; import java.util.Set; @@ -121,4 +122,27 @@ public final class KeycloakSecurityHelper { return true; } + + public static Set<String> extractPermissions(AccessToken token) { + Set<String> permissions = new HashSet<>(); + + // Extract permissions from custom claims (primary approach for simple setups) + Object permissionsClaim = token.getOtherClaims().get("permissions"); + if (permissionsClaim instanceof java.util.Collection<?>) { + @SuppressWarnings("unchecked") + java.util.Collection<String> permissionsCollection = (java.util.Collection<String>) permissionsClaim; + permissions.addAll(permissionsCollection); + } + + // Also check for scope-based permissions + Object scopesClaim = token.getOtherClaims().get("scope"); + if (scopesClaim instanceof String) { + String scopesString = (String) scopesClaim; + if (!scopesString.isEmpty()) { + permissions.addAll(java.util.Arrays.asList(scopesString.split(" "))); + } + } + + return permissions; + } } diff --git a/components/camel-keycloak/src/main/java/org/apache/camel/component/keycloak/security/KeycloakSecurityProcessor.java b/components/camel-keycloak/src/main/java/org/apache/camel/component/keycloak/security/KeycloakSecurityProcessor.java index 2338c3b66df..a47868f00f7 100644 --- a/components/camel-keycloak/src/main/java/org/apache/camel/component/keycloak/security/KeycloakSecurityProcessor.java +++ b/components/camel-keycloak/src/main/java/org/apache/camel/component/keycloak/security/KeycloakSecurityProcessor.java @@ -55,6 +55,10 @@ public class KeycloakSecurityProcessor extends DelegateProcessor { validateRoles(accessToken, exchange); } + if (!policy.getRequiredPermissions().isEmpty()) { + validatePermissions(accessToken, exchange); + } + } catch (Exception e) { exchange.getIn().setHeader(Exchange.AUTHENTICATION_FAILURE_POLICY_ID, policy.getClass().getSimpleName()); @@ -116,4 +120,35 @@ public class KeycloakSecurityProcessor extends DelegateProcessor { } } + private void validatePermissions(String accessToken, Exchange exchange) throws Exception { + try { + AccessToken token; + if (ObjectHelper.isEmpty(policy.getPublicKey())) { + token = KeycloakSecurityHelper.parseAccessToken(accessToken); + } else { + token = KeycloakSecurityHelper.parseAccessToken(accessToken, policy.getPublicKey()); + } + Set<String> userPermissions = KeycloakSecurityHelper.extractPermissions(token); + + boolean hasRequiredPermissions = policy.isAllPermissionsRequired() + ? userPermissions.containsAll(policy.getRequiredPermissions()) + : policy.getRequiredPermissions().stream().anyMatch(userPermissions::contains); + + if (!hasRequiredPermissions) { + String message = String.format("User does not have required permissions. Required: %s, User has: %s", + policy.getRequiredPermissions(), userPermissions); + LOG.debug(message); + throw new CamelAuthorizationException(message, exchange); + } + + LOG.debug("Permission validation successful for user with permissions: {}", userPermissions); + + } catch (Exception e) { + if (e instanceof CamelAuthorizationException) { + throw e; + } + throw new CamelAuthorizationException("Failed to validate permissions", exchange, e); + } + } + } diff --git a/components/camel-keycloak/src/test/java/org/apache/camel/component/keycloak/security/KeycloakSecurityHelperTest.java b/components/camel-keycloak/src/test/java/org/apache/camel/component/keycloak/security/KeycloakSecurityHelperTest.java index 718123c9dce..50372227fdc 100644 --- a/components/camel-keycloak/src/test/java/org/apache/camel/component/keycloak/security/KeycloakSecurityHelperTest.java +++ b/components/camel-keycloak/src/test/java/org/apache/camel/component/keycloak/security/KeycloakSecurityHelperTest.java @@ -160,4 +160,75 @@ public class KeycloakSecurityHelperTest { }); } + @Test + void testExtractPermissions() { + AccessToken token = Mockito.mock(AccessToken.class); + + // Mock authorization (simple approach without specific permission structure) + AccessToken.Authorization authorization = Mockito.mock(AccessToken.Authorization.class); + when(token.getAuthorization()).thenReturn(authorization); + + // Test permissions extraction from custom claims + java.util.Map<String, Object> otherClaims = new java.util.HashMap<>(); + otherClaims.put("permissions", java.util.Arrays.asList("read:documents", "write:documents", "admin:users")); + + when(token.getOtherClaims()).thenReturn(otherClaims); + + java.util.Set<String> permissions = KeycloakSecurityHelper.extractPermissions(token); + + assertEquals(3, permissions.size()); + assertTrue(permissions.contains("read:documents")); + assertTrue(permissions.contains("write:documents")); + assertTrue(permissions.contains("admin:users")); + } + + @Test + void testExtractPermissionsFromCustomClaims() { + AccessToken token = Mockito.mock(AccessToken.class); + + // Mock other claims with permissions + java.util.Map<String, Object> otherClaims = new java.util.HashMap<>(); + otherClaims.put("permissions", java.util.Arrays.asList("read:files", "write:files", "delete:files")); + + when(token.getOtherClaims()).thenReturn(otherClaims); + when(token.getAuthorization()).thenReturn(null); + + java.util.Set<String> permissions = KeycloakSecurityHelper.extractPermissions(token); + + assertEquals(3, permissions.size()); + assertTrue(permissions.contains("read:files")); + assertTrue(permissions.contains("write:files")); + assertTrue(permissions.contains("delete:files")); + } + + @Test + void testExtractPermissionsFromScopes() { + AccessToken token = Mockito.mock(AccessToken.class); + + // Mock other claims with scope-based permissions + java.util.Map<String, Object> otherClaims = new java.util.HashMap<>(); + otherClaims.put("scope", "read write admin"); + + when(token.getOtherClaims()).thenReturn(otherClaims); + when(token.getAuthorization()).thenReturn(null); + + java.util.Set<String> permissions = KeycloakSecurityHelper.extractPermissions(token); + + assertEquals(3, permissions.size()); + assertTrue(permissions.contains("read")); + assertTrue(permissions.contains("write")); + assertTrue(permissions.contains("admin")); + } + + @Test + void testExtractPermissionsEmpty() { + AccessToken token = Mockito.mock(AccessToken.class); + when(token.getAuthorization()).thenReturn(null); + when(token.getOtherClaims()).thenReturn(java.util.Map.of()); + + java.util.Set<String> permissions = KeycloakSecurityHelper.extractPermissions(token); + + assertTrue(permissions.isEmpty()); + } + } diff --git a/components/camel-keycloak/src/test/java/org/apache/camel/component/keycloak/security/KeycloakSecurityIT.java b/components/camel-keycloak/src/test/java/org/apache/camel/component/keycloak/security/KeycloakSecurityIT.java index 05169c82b4a..ea315ba2e0d 100644 --- a/components/camel-keycloak/src/test/java/org/apache/camel/component/keycloak/security/KeycloakSecurityIT.java +++ b/components/camel-keycloak/src/test/java/org/apache/camel/component/keycloak/security/KeycloakSecurityIT.java @@ -260,6 +260,111 @@ public class KeycloakSecurityIT extends CamelTestSupport { assertTrue(ex.getMessage().contains("signature") || ex.getMessage().contains("verification")); } + @Test + void testKeycloakSecurityPolicyWithPermissions() { + // Test permissions-based authorization + String adminToken = getAccessToken("myuser", "pippo123"); + assertNotNull(adminToken); + + // Test with permissions policy route - this will test the permissions extraction + try { + String result = template.requestBodyAndHeader("direct:permissions-protected", "test message", + KeycloakSecurityConstants.ACCESS_TOKEN_HEADER, adminToken, String.class); + assertEquals("Permissions access granted", result); + } catch (CamelExecutionException ex) { + // This might fail if permissions are not configured in the token + // which is expected in a basic Keycloak setup + assertTrue(ex.getCause() instanceof CamelAuthorizationException); + } + } + + @Test + void testKeycloakSecurityPolicyWithInsufficientPermissions() { + // Test that users without required permissions are rejected + String userToken = getAccessToken("test-user", "user123"); + assertNotNull(userToken); + + CamelExecutionException ex = assertThrows(CamelExecutionException.class, () -> { + template.sendBodyAndHeader("direct:permissions-protected", "test message", + KeycloakSecurityConstants.ACCESS_TOKEN_HEADER, userToken); + }); + assertTrue(ex.getCause() instanceof CamelAuthorizationException); + } + + @Test + void testKeycloakSecurityPolicyWithScopeBasedPermissions() { + // Test scope-based permissions (using standard OAuth2 scopes) + String adminToken = getAccessToken("myuser", "pippo123"); + assertNotNull(adminToken); + + // Test with scope-based permissions policy route + try { + String result = template.requestBodyAndHeader("direct:scope-permissions-protected", "test message", + KeycloakSecurityConstants.ACCESS_TOKEN_HEADER, adminToken, String.class); + assertEquals("Scope permissions access granted", result); + } catch (CamelExecutionException ex) { + // This might fail if the token doesn't contain the expected scopes + // which is expected in basic Keycloak setup without custom scope configuration + assertTrue(ex.getCause() instanceof CamelAuthorizationException); + } + } + + @Test + void testKeycloakSecurityPolicyWithCombinedRolesAndPermissions() { + // Test combined roles and permissions validation + String adminToken = getAccessToken("myuser", "pippo123"); + assertNotNull(adminToken); + + // Test with combined policy route (requires BOTH admin role AND permissions) + try { + String result = template.requestBodyAndHeader("direct:combined-protected", "test message", + KeycloakSecurityConstants.ACCESS_TOKEN_HEADER, adminToken, String.class); + assertEquals("Combined access granted", result); + } catch (CamelExecutionException ex) { + // This will fail if either role or permissions are missing + assertTrue(ex.getCause() instanceof CamelAuthorizationException); + } + } + + @Test + void testKeycloakSecurityPolicyWithFlexiblePermissions() { + // Test flexible permissions (ANY permission required) + String userToken = getAccessToken("test-user", "user123"); + assertNotNull(userToken); + + // Test with flexible permissions policy (requires ANY of the specified permissions) + try { + String result = template.requestBodyAndHeader("direct:flexible-permissions-protected", "test message", + KeycloakSecurityConstants.ACCESS_TOKEN_HEADER, userToken, String.class); + assertEquals("Flexible permissions access granted", result); + } catch (CamelExecutionException ex) { + // This will fail if the user doesn't have any of the required permissions + assertTrue(ex.getCause() instanceof CamelAuthorizationException); + } + } + + @Test + void testPermissionsExtractionFromToken() { + // Test direct permissions extraction from token for debugging + String adminToken = getAccessToken("myuser", "pippo123"); + assertNotNull(adminToken); + + try { + // Parse token and extract permissions directly + org.keycloak.representations.AccessToken token = KeycloakSecurityHelper.parseAccessToken(adminToken); + java.util.Set<String> permissions = KeycloakSecurityHelper.extractPermissions(token); + + // Log the permissions found for debugging + System.out.println("Permissions found in token: " + permissions); + + // Permissions might be empty in a basic setup, which is expected + assertNotNull(permissions); + + } catch (Exception e) { + fail("Should be able to parse token and extract permissions: " + e.getMessage()); + } + } + /** * Helper method to get public key from Keycloak JWKS endpoint for token verification. Tries to find the key with * "sig" usage or the first RSA key available. @@ -451,6 +556,66 @@ public class KeycloakSecurityIT extends CamelTestSupport { .policy(wrongPublicKeyPolicy) .transform().constant("Should not reach here") .to("mock:wrong-key-result"); + + // Permissions-based policy + KeycloakSecurityPolicy permissionsPolicy = new KeycloakSecurityPolicy(); + permissionsPolicy.setServerUrl(keycloakUrl); + permissionsPolicy.setRealm(realm); + permissionsPolicy.setClientId(clientId); + permissionsPolicy.setClientSecret(clientSecret); + permissionsPolicy.setRequiredPermissions(java.util.Arrays.asList("read:documents", "write:documents")); + permissionsPolicy.setAllPermissionsRequired(false); // ANY permission + + from("direct:permissions-protected") + .policy(permissionsPolicy) + .transform().constant("Permissions access granted") + .to("mock:permissions-result"); + + // Scope-based permissions policy (using OAuth2 scopes) + KeycloakSecurityPolicy scopePermissionsPolicy = new KeycloakSecurityPolicy(); + scopePermissionsPolicy.setServerUrl(keycloakUrl); + scopePermissionsPolicy.setRealm(realm); + scopePermissionsPolicy.setClientId(clientId); + scopePermissionsPolicy.setClientSecret(clientSecret); + scopePermissionsPolicy.setRequiredPermissions(java.util.Arrays.asList("profile", "email", "openid")); + scopePermissionsPolicy.setAllPermissionsRequired(false); // ANY scope + + from("direct:scope-permissions-protected") + .policy(scopePermissionsPolicy) + .transform().constant("Scope permissions access granted") + .to("mock:scope-permissions-result"); + + // Combined roles and permissions policy + KeycloakSecurityPolicy combinedPolicy = new KeycloakSecurityPolicy(); + combinedPolicy.setServerUrl(keycloakUrl); + combinedPolicy.setRealm(realm); + combinedPolicy.setClientId(clientId); + combinedPolicy.setClientSecret(clientSecret); + combinedPolicy.setRequiredRoles(java.util.Arrays.asList("admin-role")); + combinedPolicy.setRequiredPermissions(java.util.Arrays.asList("read:documents", "admin:system")); + combinedPolicy.setAllRolesRequired(true); // Must have ALL roles + combinedPolicy.setAllPermissionsRequired(true); // Any permission + + from("direct:combined-protected") + .policy(combinedPolicy) + .transform().constant("Combined access granted") + .to("mock:combined-result"); + + // Flexible permissions policy (ANY permission) + KeycloakSecurityPolicy flexiblePermissionsPolicy = new KeycloakSecurityPolicy(); + flexiblePermissionsPolicy.setServerUrl(keycloakUrl); + flexiblePermissionsPolicy.setRealm(realm); + flexiblePermissionsPolicy.setClientId(clientId); + flexiblePermissionsPolicy.setClientSecret(clientSecret); + flexiblePermissionsPolicy + .setRequiredPermissions(java.util.Arrays.asList("profile", "email", "user:basic", "read:public")); + flexiblePermissionsPolicy.setAllPermissionsRequired(false); // ANY permission + + from("direct:flexible-permissions-protected") + .policy(flexiblePermissionsPolicy) + .transform().constant("Flexible permissions access granted") + .to("mock:flexible-permissions-result"); + } }; }
