diff --git a/services/src/main/java/org/keycloak/authorization/admin/PolicyEvaluationService.java b/services/src/main/java/org/keycloak/authorization/admin/PolicyEvaluationService.java index 200a5b4b3db..32635fc31a2 100644 --- a/services/src/main/java/org/keycloak/authorization/admin/PolicyEvaluationService.java +++ b/services/src/main/java/org/keycloak/authorization/admin/PolicyEvaluationService.java @@ -45,6 +45,7 @@ import org.keycloak.authorization.admin.representation.PolicyEvaluationResponseB import org.keycloak.authorization.attribute.Attributes; import org.keycloak.authorization.common.DefaultEvaluationContext; import org.keycloak.authorization.common.KeycloakIdentity; +import org.keycloak.authorization.common.TokenIdentityEnricher; import org.keycloak.authorization.fgap.AdminPermissionsSchema; import org.keycloak.authorization.model.Resource; import org.keycloak.authorization.model.ResourceServer; @@ -380,15 +381,7 @@ public class PolicyEvaluationService { UserModel user = keycloakSession.users().getUserById(realm, representation.getUserId()); if (user != null) { - AccessToken finalAccessToken = accessToken; - user.getRoleMappingsStream().forEach(roleModel -> { - if (roleModel.isClientRole()) { - ClientModel client = (ClientModel) roleModel.getContainer(); - finalAccessToken.addAccess(client.getClientId()).addRole(roleModel.getName()); - } else { - realmAccess.addRole(roleModel.getName()); - } - }); + TokenIdentityEnricher.addAllUserRoles(accessToken, user); } } diff --git a/services/src/main/java/org/keycloak/authorization/common/TokenIdentityEnricher.java b/services/src/main/java/org/keycloak/authorization/common/TokenIdentityEnricher.java new file mode 100644 index 00000000000..f9e8346d095 --- /dev/null +++ b/services/src/main/java/org/keycloak/authorization/common/TokenIdentityEnricher.java @@ -0,0 +1,88 @@ +/* + * Copyright 2026 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.authorization.common; + +import org.keycloak.models.ClientModel; +import org.keycloak.models.UserModel; +import org.keycloak.representations.AccessToken; + +/** + * Utilities for projecting a user's full role assignments onto an + * {@link AccessToken} so that authorization evaluation has visibility into + * roles that are otherwise filtered out by OIDC scope configuration. + * + *
A {@link KeycloakIdentity} constructed directly from a bearer access + * token derives its role attributes from the token's + * {@code realm_access} and {@code resource_access} claims. When a policy + * references a role defined in a client other than the resource server, and + * that client is not part of the requesting client's scope, the role will + * be absent from the token and the policy will evaluate to DENY even when + * the user has been granted the role. + * + *
This helper centralizes the enrichment logic the Keycloak admin + * console already uses internally when evaluating policies on behalf of a + * user. Extensions performing programmatic permission evaluation can use it + * to obtain a {@code KeycloakIdentity} consistent with the admin console's + * behavior: + * + *
{@code
+ * AccessToken token = Tokens.getAccessToken(session);
+ * UserModel user = session.users().getUserById(realm, token.getSubject());
+ * TokenIdentityEnricher.addAllUserRoles(token, user);
+ * Identity identity = new KeycloakIdentity(token, session, realm);
+ * }
+ */
+public final class TokenIdentityEnricher {
+
+ private TokenIdentityEnricher() {
+ }
+
+ /**
+ * Adds every role mapping of {@code user} to {@code token}. Realm roles
+ * are added to {@code realm_access}; client roles are added to
+ * {@code resource_access} under their owning client. Existing roles on
+ * the token are preserved.
+ *
+ * @param token the access token to enrich; must not be {@code null}
+ * @param user the user whose role mappings will be projected onto the
+ * token; must not be {@code null}
+ * @throws IllegalArgumentException if {@code token} or {@code user} is
+ * {@code null}
+ */
+ public static void addAllUserRoles(AccessToken token, UserModel user) {
+ if (token == null) {
+ throw new IllegalArgumentException("token must not be null");
+ }
+ if (user == null) {
+ throw new IllegalArgumentException("user must not be null");
+ }
+
+ user.getRoleMappingsStream().forEach(roleModel -> {
+ if (roleModel.isClientRole()) {
+ ClientModel client = (ClientModel) roleModel.getContainer();
+ token.addAccess(client.getClientId()).addRole(roleModel.getName());
+ } else {
+ AccessToken.Access realmAccess = token.getRealmAccess();
+ if (realmAccess == null) {
+ realmAccess = new AccessToken.Access();
+ token.setRealmAccess(realmAccess);
+ }
+ realmAccess.addRole(roleModel.getName());
+ }
+ });
+ }
+}
diff --git a/services/src/test/java/org/keycloak/authorization/common/TokenIdentityEnricherTest.java b/services/src/test/java/org/keycloak/authorization/common/TokenIdentityEnricherTest.java
new file mode 100644
index 00000000000..877ff192200
--- /dev/null
+++ b/services/src/test/java/org/keycloak/authorization/common/TokenIdentityEnricherTest.java
@@ -0,0 +1,49 @@
+/*
+ * Copyright 2026 Red Hat, Inc. and/or its affiliates
+ * and other contributors as indicated by the @author tags.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.keycloak.authorization.common;
+
+import org.keycloak.representations.AccessToken;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+/**
+ * Boundary-condition tests for {@link TokenIdentityEnricher}.
+ *
+ * Functional coverage of the enrichment behavior (realm vs. client roles, + * cross-client role projection) is provided by the integration test + * {@code org.keycloak.testsuite.authz.KeycloakIdentityCrossClientRoleTest}, + * which exercises the helper end-to-end against a running Keycloak session. + */ +class TokenIdentityEnricherTest { + + @Test + void addAllUserRoles_rejectsNullToken() { + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> TokenIdentityEnricher.addAllUserRoles(null, null)); + assertEquals("token must not be null", ex.getMessage()); + } + + @Test + void addAllUserRoles_rejectsNullUser() { + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> TokenIdentityEnricher.addAllUserRoles(new AccessToken(), null)); + assertEquals("user must not be null", ex.getMessage()); + } +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/authz/KeycloakIdentityCrossClientRoleTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/authz/KeycloakIdentityCrossClientRoleTest.java new file mode 100644 index 00000000000..b76595cee4f --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/authz/KeycloakIdentityCrossClientRoleTest.java @@ -0,0 +1,250 @@ +/* + * Copyright 2026 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.testsuite.authz; + +import java.util.Arrays; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.keycloak.admin.client.resource.RealmResource; +import org.keycloak.authorization.AuthorizationProvider; +import org.keycloak.authorization.AuthorizationProviderFactory; +import org.keycloak.authorization.common.DefaultEvaluationContext; +import org.keycloak.authorization.common.KeycloakIdentity; +import org.keycloak.authorization.common.TokenIdentityEnricher; +import org.keycloak.authorization.model.Policy; +import org.keycloak.authorization.model.Resource; +import org.keycloak.authorization.model.ResourceServer; +import org.keycloak.authorization.model.Scope; +import org.keycloak.authorization.permission.ResourcePermission; +import org.keycloak.authorization.permission.evaluator.PermissionEvaluator; +import org.keycloak.models.ClientModel; +import org.keycloak.models.ClientSessionContext; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.RoleModel; +import org.keycloak.models.UserModel; +import org.keycloak.models.UserSessionModel; +import org.keycloak.protocol.oidc.OIDCLoginProtocol; +import org.keycloak.protocol.oidc.TokenManager; +import org.keycloak.representations.AccessToken; +import org.keycloak.representations.idm.RealmRepresentation; +import org.keycloak.representations.idm.UserRepresentation; +import org.keycloak.representations.idm.authorization.DecisionEffect; +import org.keycloak.representations.idm.authorization.DecisionStrategy; +import org.keycloak.representations.idm.authorization.Logic; +import org.keycloak.representations.idm.authorization.Permission; +import org.keycloak.representations.idm.authorization.PolicyEvaluationRequest; +import org.keycloak.representations.idm.authorization.PolicyEvaluationResponse; +import org.keycloak.representations.idm.authorization.PolicyRepresentation; +import org.keycloak.representations.idm.authorization.ScopePermissionRepresentation; +import org.keycloak.services.managers.AuthenticationManager; +import org.keycloak.services.managers.UserSessionManager; +import org.keycloak.sessions.AuthenticationSessionModel; + +import org.junit.Test; +import org.junit.jupiter.api.Assertions; + +import static org.keycloak.testsuite.auth.page.AuthRealm.TEST; + +/** + * Demonstrates the cross-client role gap addressed by the + * {@code TokenIdentityEnricher} contribution. + * + *
Scenario: a resource on {@code client-a} is protected by a role policy + * referencing a client role defined in {@code client-b}. The user holds the + * {@code client-b} role. + * + *