Browse Source

Add info of effective roles in denial messages (#89680)

When an action is denied due to authorization error, the list of
assigned roles is shown in the error message. However, it is possible
that the effective roles are fewer or more than the assigned list: *
Fewer roles can happen when the role is not defined or the license does
not permit it * More roles can happen when anonymous access is enabled

This PR changes the error message to show the effective roles instead of
the assigned roles (whenever possible) to help troubleshooting. In
addition, it also reports any missing roles, i.e. roles that are
assigned but cannot be found.
Yang Wang 3 years ago
parent
commit
adf8e01286
14 changed files with 341 additions and 56 deletions
  1. 5 0
      docs/changelog/89680.yaml
  2. 12 0
      x-pack/plugin/build.gradle
  3. 1 1
      x-pack/plugin/ilm/qa/with-security/src/javaRestTest/java/org/elasticsearch/xpack/security/PermissionsIT.java
  4. 2 2
      x-pack/plugin/ml/qa/native-multi-node-tests/src/javaRestTest/java/org/elasticsearch/xpack/ml/integration/DatafeedJobsRestIT.java
  5. 3 1
      x-pack/plugin/security/src/internalClusterTest/java/org/elasticsearch/integration/SecurityFeatureResetTests.java
  6. 6 3
      x-pack/plugin/security/src/internalClusterTest/java/org/elasticsearch/xpack/security/authc/apikey/ApiKeySingleNodeTests.java
  7. 63 9
      x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/AuthorizationDenialMessages.java
  8. 33 16
      x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/AuthorizationService.java
  9. 14 5
      x-pack/plugin/security/src/test/java/org/elasticsearch/test/SecurityTestsUtils.java
  10. 175 0
      x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/AuthorizationDenialMessagesTests.java
  11. 20 13
      x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/AuthorizationServiceTests.java
  12. 2 2
      x-pack/plugin/src/yamlRestTest/resources/rest-api-spec/test/api_key/11_invalidation.yml
  13. 3 3
      x-pack/plugin/src/yamlRestTest/resources/rest-api-spec/test/api_key/12_grant.yml
  14. 2 1
      x-pack/qa/runtime-fields/with-security/src/javaRestTest/java/org/elasticsearch/xpack/security/PermissionsIT.java

+ 5 - 0
docs/changelog/89680.yaml

@@ -0,0 +1,5 @@
+pr: 89680
+summary: Add info of resolved roles in denial messages
+area: Authorization
+type: enhancement
+issues: []

+ 12 - 0
x-pack/plugin/build.gradle

@@ -157,6 +157,18 @@ tasks.named("yamlRestTestV7CompatTransform").configure { task ->
     '/It is no longer possible to freeze indices, but existing frozen indices can still be unfrozen/',
     "Cannot freeze write index for data stream"
   )
+
+  task.replaceValueInMatch(
+    "error.reason",
+    "action [cluster:admin/xpack/security/api_key/invalidate] is unauthorized for user [api_key_user_1] with effective roles [user_role], this action is granted by the cluster privileges [manage_api_key,manage_security,all]",
+    "Test invalidate api key by realm name"
+  )
+
+  task.replaceValueInMatch(
+    "error.reason",
+    "action [cluster:admin/xpack/security/api_key/invalidate] is unauthorized for user [api_key_user_1] with effective roles [user_role], this action is granted by the cluster privileges [manage_api_key,manage_security,all]",
+    "Test invalidate api key by username"
+  )
 }
 
 

+ 1 - 1
x-pack/plugin/ilm/qa/with-security/src/javaRestTest/java/org/elasticsearch/xpack/security/PermissionsIT.java

@@ -143,7 +143,7 @@ public class PermissionsIT extends ESRestTestCase {
                         equalTo(
                             "action [indices:monitor/stats] is unauthorized"
                                 + " for user [test_ilm]"
-                                + " with roles [ilm]"
+                                + " with effective roles [ilm]"
                                 + " on indices [not-ilm],"
                                 + " this action is granted by the index privileges [monitor,manage,all]"
                         )

+ 2 - 2
x-pack/plugin/ml/qa/native-multi-node-tests/src/javaRestTest/java/org/elasticsearch/xpack/ml/integration/DatafeedJobsRestIT.java

@@ -1086,7 +1086,7 @@ public class DatafeedJobsRestIT extends ESRestTestCase {
                 "\"message\":\"Datafeed is encountering errors extracting data: "
                     + "action [indices:data/read/search] is unauthorized"
                     + " for user [ml_admin_plus_data]"
-                    + " with roles [machine_learning_admin,test_data_access]"
+                    + " with effective roles [machine_learning_admin,test_data_access]"
                     + " on indices [network-data]"
             )
         );
@@ -1286,7 +1286,7 @@ public class DatafeedJobsRestIT extends ESRestTestCase {
                 "\"message\":\"Datafeed is encountering errors extracting data: "
                     + "action [indices:data/read/xpack/rollup/search] is unauthorized"
                     + " for user [ml_admin_plus_data]"
-                    + " with roles [machine_learning_admin,test_data_access]"
+                    + " with effective roles [machine_learning_admin,test_data_access]"
                     + " on indices [airline-data-aggs-rollup]"
             )
         );

+ 3 - 1
x-pack/plugin/security/src/internalClusterTest/java/org/elasticsearch/integration/SecurityFeatureResetTests.java

@@ -112,7 +112,9 @@ public class SecurityFeatureResetTests extends SecurityIntegTestCase {
                 public void onFailure(Exception e) {
                     assertThat(
                         e.getMessage(),
-                        containsString("action [cluster:admin/features/reset] is unauthorized for user [usr] with roles [role2]")
+                        containsString(
+                            "action [cluster:admin/features/reset] is unauthorized for user [usr]" + " with effective roles [role2]"
+                        )
                     );
                 }
             });

+ 6 - 3
x-pack/plugin/security/src/internalClusterTest/java/org/elasticsearch/xpack/security/authc/apikey/ApiKeySingleNodeTests.java

@@ -308,7 +308,8 @@ public class ApiKeySingleNodeTests extends SecuritySingleNodeTestCase {
             e1.getMessage(),
             containsString(
                 "action [cluster:admin/xpack/security/user/authenticate] is unauthorized "
-                    + "for user [user1] because user [user1] is unauthorized to run as [user3]"
+                    + "for user [user1] with effective roles [user1_role]"
+                    + ", because user [user1] is unauthorized to run as [user3]"
             )
         );
 
@@ -322,7 +323,8 @@ public class ApiKeySingleNodeTests extends SecuritySingleNodeTestCase {
             e2.getMessage(),
             containsString(
                 "action [cluster:admin/xpack/security/user/authenticate] is unauthorized "
-                    + "for user [user1] because user [user1] is unauthorized to run as [user4]"
+                    + "for user [user1] with effective roles [user1_role]"
+                    + ", because user [user1] is unauthorized to run as [user4]"
             )
         );
 
@@ -368,7 +370,8 @@ public class ApiKeySingleNodeTests extends SecuritySingleNodeTestCase {
             e4.getMessage(),
             containsString(
                 "action [cluster:admin/xpack/security/user/authenticate] is unauthorized "
-                    + "for user [user1] because user [user1] is unauthorized to run as [user4]"
+                    + "for user [user1] with effective roles [user1_role]"
+                    + ", because user [user1] is unauthorized to run as [user4]"
             )
         );
     }

+ 63 - 9
x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/AuthorizationDenialMessages.java

@@ -8,23 +8,33 @@
 package org.elasticsearch.xpack.security.authz;
 
 import org.elasticsearch.common.Strings;
+import org.elasticsearch.common.util.set.Sets;
 import org.elasticsearch.core.Nullable;
 import org.elasticsearch.transport.TransportRequest;
 import org.elasticsearch.xpack.core.security.authc.Authentication;
 import org.elasticsearch.xpack.core.security.authc.AuthenticationField;
+import org.elasticsearch.xpack.core.security.authc.Subject;
+import org.elasticsearch.xpack.core.security.authz.AuthorizationEngine.AuthorizationInfo;
+import org.elasticsearch.xpack.core.security.authz.permission.Role;
 import org.elasticsearch.xpack.core.security.authz.privilege.ClusterPrivilegeResolver;
 import org.elasticsearch.xpack.core.security.authz.privilege.IndexPrivilege;
 
+import java.util.Arrays;
 import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.SortedSet;
 
 import static org.elasticsearch.common.Strings.collectionToCommaDelimitedString;
+import static org.elasticsearch.xpack.security.audit.logfile.LoggingAuditTrail.PRINCIPAL_ROLES_FIELD_NAME;
 import static org.elasticsearch.xpack.security.authz.AuthorizationService.isIndexAction;
 
 class AuthorizationDenialMessages {
 
     private AuthorizationDenialMessages() {}
 
-    static String runAsDenied(Authentication authentication, String action) {
+    static String runAsDenied(Authentication authentication, @Nullable AuthorizationInfo authorizationInfo, String action) {
         assert authentication.isRunAs() : "constructing run as denied message but authentication for action was not run as";
 
         String userText = authenticatedUserDescription(authentication);
@@ -36,22 +46,26 @@ class AuthorizationDenialMessages {
             + authentication.getUser().principal()
             + "]";
 
-        return actionIsUnauthorizedMessage + " " + unauthorizedToRunAsMessage;
+        return actionIsUnauthorizedMessage
+            + rolesDescription(authentication.getAuthenticatingSubject(), authorizationInfo.getAuthenticatedUserAuthorizationInfo())
+            + ", "
+            + unauthorizedToRunAsMessage;
     }
 
-    static String actionDenied(Authentication authentication, String action, TransportRequest request, @Nullable String context) {
+    static String actionDenied(
+        Authentication authentication,
+        @Nullable AuthorizationInfo authorizationInfo,
+        String action,
+        TransportRequest request,
+        @Nullable String context
+    ) {
         String userText = authenticatedUserDescription(authentication);
 
         if (authentication.isRunAs()) {
             userText = userText + " run as [" + authentication.getUser().principal() + "]";
         }
 
-        // The run-as user is always from a realm. So it must have roles that can be printed.
-        // If the user is not run-as, we cannot print the roles if it's an API key or a service account (both do not have
-        // roles, but privileges)
-        if (false == authentication.isServiceAccount() && false == authentication.isApiKey()) {
-            userText = userText + " with roles [" + Strings.arrayToCommaDelimitedString(authentication.getUser().roles()) + "]";
-        }
+        userText += rolesDescription(authentication.getEffectiveSubject(), authorizationInfo);
 
         String message = actionIsUnauthorizedMessage(action, userText);
         if (context != null) {
@@ -92,6 +106,46 @@ class AuthorizationDenialMessages {
         return userText;
     }
 
+    static String rolesDescription(Subject subject, @Nullable AuthorizationInfo authorizationInfo) {
+        // We cannot print the roles if it's an API key or a service account (both do not have roles, but privileges)
+        if (subject.getType() != Subject.Type.USER) {
+            return "";
+        }
+
+        final StringBuilder sb = new StringBuilder();
+        final List<String> effectiveRoleNames = extractEffectiveRoleNames(authorizationInfo);
+        if (effectiveRoleNames == null) {
+            sb.append(" with assigned roles [").append(Strings.arrayToCommaDelimitedString(subject.getUser().roles())).append("]");
+        } else {
+            sb.append(" with effective roles [").append(Strings.collectionToCommaDelimitedString(effectiveRoleNames)).append("]");
+
+            final Set<String> assignedRoleNames = Set.of(subject.getUser().roles());
+            final SortedSet<String> unfoundedRoleNames = Sets.sortedDifference(assignedRoleNames, Set.copyOf(effectiveRoleNames));
+            if (false == unfoundedRoleNames.isEmpty()) {
+                sb.append(" (assigned roles [")
+                    .append(Strings.collectionToCommaDelimitedString(unfoundedRoleNames))
+                    .append("] were not found)");
+            }
+        }
+        return sb.toString();
+    }
+
+    private static List<String> extractEffectiveRoleNames(@Nullable AuthorizationInfo authorizationInfo) {
+        if (authorizationInfo == null) {
+            return null;
+        }
+        final Role role = RBACEngine.maybeGetRBACEngineRole(authorizationInfo);
+        if (role == Role.EMPTY) {
+            return List.of();
+        } else {
+            final Map<String, Object> info = authorizationInfo.asMap();
+            if (false == info.containsKey(PRINCIPAL_ROLES_FIELD_NAME)) {
+                return null;
+            }
+            return Arrays.stream((String[]) info.get(PRINCIPAL_ROLES_FIELD_NAME)).sorted().toList();
+        }
+    }
+
     private static String actionIsUnauthorizedMessage(String action, String userText) {
         return "action [" + action + "] is unauthorized for " + userText;
     }

+ 33 - 16
x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/AuthorizationService.java

@@ -328,7 +328,7 @@ public class AuthorizationService {
             threadContext
         );
         if (operatorException != null) {
-            throw actionDenied(authentication, action, originalRequest, "because it requires operator privileges", operatorException);
+            throw actionDenied(authentication, null, action, originalRequest, "because it requires operator privileges", operatorException);
         }
         operatorPrivilegesService.maybeInterceptRequest(threadContext, originalRequest);
     }
@@ -367,11 +367,11 @@ public class AuthorizationService {
                             authzInfo.getAuthenticatedUserAuthorizationInfo()
                         );
                     }
-                    listener.onFailure(runAsDenied(authentication, action));
+                    listener.onFailure(runAsDenied(authentication, authzInfo, action));
                 }
             }, e -> {
                 auditTrail.runAsDenied(requestId, authentication, action, request, authzInfo.getAuthenticatedUserAuthorizationInfo());
-                listener.onFailure(actionDenied(authentication, action, request));
+                listener.onFailure(actionDenied(authentication, authzInfo, action, request));
             }), threadContext);
             authorizeRunAs(requestInfo, authzInfo, runAsListener);
         } else {
@@ -431,7 +431,7 @@ public class AuthorizationService {
                                 if (e instanceof IndexNotFoundException) {
                                     listener.onFailure(e);
                                 } else {
-                                    listener.onFailure(actionDenied(authentication, action, request, e));
+                                    listener.onFailure(actionDenied(authentication, authzInfo, action, request, e));
                                 }
                             }
                         )
@@ -466,7 +466,7 @@ public class AuthorizationService {
         } else {
             logger.warn("denying access as action [{}] is not an index or cluster action", action);
             auditTrail.accessDenied(requestId, authentication, action, request, authzInfo);
-            listener.onFailure(actionDenied(authentication, action, request));
+            listener.onFailure(actionDenied(authentication, authzInfo, action, request));
         }
     }
 
@@ -620,7 +620,7 @@ public class AuthorizationService {
             listener.onResponse(null);
         } else {
             auditTrail.accessDenied(requestId, authentication, action, request, SYSTEM_AUTHZ_INFO);
-            listener.onFailure(actionDenied(authentication, action, request));
+            listener.onFailure(actionDenied(authentication, SYSTEM_AUTHZ_INFO, action, request));
         }
     }
 
@@ -644,14 +644,14 @@ public class AuthorizationService {
                     "originalRequest is not a proxy request: [" + originalRequest + "] but action: [" + action + "] is a proxy action"
                 );
                 auditTrail.accessDenied(requestId, authentication, action, request, EmptyAuthorizationInfo.INSTANCE);
-                throw actionDenied(authentication, action, request, cause);
+                throw actionDenied(authentication, null, action, request, cause);
             }
             if (TransportActionProxy.isProxyRequest(originalRequest) && TransportActionProxy.isProxyAction(action) == false) {
                 IllegalStateException cause = new IllegalStateException(
                     "originalRequest is a proxy request for: [" + request + "] but action: [" + action + "] isn't"
                 );
                 auditTrail.accessDenied(requestId, authentication, action, request, EmptyAuthorizationInfo.INSTANCE);
-                throw actionDenied(authentication, action, request, cause);
+                throw actionDenied(authentication, null, action, request, cause);
             }
         }
         return request;
@@ -680,7 +680,7 @@ public class AuthorizationService {
      * and then checks whether that action is allowed on the targeted index. Items
      * that fail this checks are {@link BulkItemRequest#abort(String, Exception)
      * aborted}, with an
-     * {@link #actionDenied(Authentication, String, TransportRequest, String, Exception)
+     * {@link #actionDenied(Authentication, AuthorizationInfo, String, TransportRequest, String, Exception)
      * access denied} exception. Because a shard level request is for exactly 1 index,
      * and there are a small number of possible item {@link DocWriteRequest.OpType
      * types}, the number of distinct authorization checks that need to be performed
@@ -781,6 +781,7 @@ public class AuthorizationService {
                                 resolvedIndex,
                                 actionDenied(
                                     authentication,
+                                    authzInfo,
                                     itemAction,
                                     request,
                                     IndexAuthorizationResult.getFailureDescription(List.of(resolvedIndex), restrictedIndices),
@@ -856,25 +857,41 @@ public class AuthorizationService {
         }
     }
 
-    private ElasticsearchSecurityException runAsDenied(Authentication authentication, String action) {
-        return denialException(authentication, action, () -> AuthorizationDenialMessages.runAsDenied(authentication, action), null);
+    private ElasticsearchSecurityException runAsDenied(
+        Authentication authentication,
+        @Nullable AuthorizationInfo authorizationInfo,
+        String action
+    ) {
+        return denialException(
+            authentication,
+            action,
+            () -> AuthorizationDenialMessages.runAsDenied(authentication, authorizationInfo, action),
+            null
+        );
     }
 
-    private ElasticsearchSecurityException actionDenied(Authentication authentication, String action, TransportRequest request) {
-        return actionDenied(authentication, action, request, null);
+    private ElasticsearchSecurityException actionDenied(
+        Authentication authentication,
+        @Nullable AuthorizationInfo authorizationInfo,
+        String action,
+        TransportRequest request
+    ) {
+        return actionDenied(authentication, authorizationInfo, action, request, null);
     }
 
     private ElasticsearchSecurityException actionDenied(
         Authentication authentication,
+        @Nullable AuthorizationInfo authorizationInfo,
         String action,
         TransportRequest request,
         Exception cause
     ) {
-        return actionDenied(authentication, action, request, null, cause);
+        return actionDenied(authentication, authorizationInfo, action, request, null, cause);
     }
 
     private ElasticsearchSecurityException actionDenied(
         Authentication authentication,
+        @Nullable AuthorizationInfo authorizationInfo,
         String action,
         TransportRequest request,
         @Nullable String context,
@@ -883,7 +900,7 @@ public class AuthorizationService {
         return denialException(
             authentication,
             action,
-            () -> AuthorizationDenialMessages.actionDenied(authentication, action, request, context),
+            () -> AuthorizationDenialMessages.actionDenied(authentication, authorizationInfo, action, request, context),
             cause
         );
     }
@@ -963,7 +980,7 @@ public class AuthorizationService {
             if (audit) {
                 auditTrailService.get().accessDenied(requestId, authentication, action, request, authzInfo);
             }
-            failureConsumer.accept(actionDenied(authentication, action, request, context, e));
+            failureConsumer.accept(actionDenied(authentication, authzInfo, action, request, context, e));
         }
     }
 

+ 14 - 5
x-pack/plugin/security/src/test/java/org/elasticsearch/test/SecurityTestsUtils.java

@@ -11,6 +11,7 @@ import org.elasticsearch.ElasticsearchSecurityException;
 import org.elasticsearch.common.Strings;
 import org.elasticsearch.license.LicenseUtils;
 import org.elasticsearch.rest.RestStatus;
+import org.elasticsearch.xpack.core.security.user.User;
 import org.hamcrest.Matcher;
 
 import static org.apache.lucene.tests.util.LuceneTestCase.expectThrows;
@@ -62,19 +63,27 @@ public class SecurityTestsUtils {
     public static void assertThrowsAuthorizationExceptionRunAsDenied(
         LuceneTestCase.ThrowingRunnable throwingRunnable,
         String action,
-        String user,
+        User authenticatingUser,
         String runAs
     ) {
         assertThrowsAuthorizationException(
-            "Expected authorization failure for user=[" + user + "], run-as=[" + runAs + "], action=[" + action + "]",
+            "Expected authorization failure for user=["
+                + authenticatingUser.principal()
+                + "], run-as=["
+                + runAs
+                + "], action=["
+                + action
+                + "]",
             throwingRunnable,
             containsString(
                 "action ["
                     + action
                     + "] is unauthorized for user ["
-                    + user
-                    + "] because user ["
-                    + user
+                    + authenticatingUser.principal()
+                    + "]"
+                    + " with effective roles [%s]".formatted(Strings.arrayToCommaDelimitedString(authenticatingUser.roles()))
+                    + ", because user ["
+                    + authenticatingUser.principal()
                     + "] is unauthorized to run as ["
                     + runAs
                     + "]"

+ 175 - 0
x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/AuthorizationDenialMessagesTests.java

@@ -0,0 +1,175 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.security.authz;
+
+import org.elasticsearch.common.Strings;
+import org.elasticsearch.test.ESTestCase;
+import org.elasticsearch.xpack.core.security.authc.Authentication;
+import org.elasticsearch.xpack.core.security.authc.AuthenticationTestHelper;
+import org.elasticsearch.xpack.core.security.authc.Subject;
+import org.elasticsearch.xpack.core.security.authz.AuthorizationEngine.AuthorizationInfo;
+import org.elasticsearch.xpack.core.security.authz.permission.Role;
+import org.elasticsearch.xpack.core.security.user.User;
+
+import java.util.List;
+import java.util.Map;
+import java.util.stream.IntStream;
+import java.util.stream.Stream;
+
+import static org.hamcrest.Matchers.equalTo;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+public class AuthorizationDenialMessagesTests extends ESTestCase {
+
+    public void testNoRolesDescriptionIfSubjectIsNotAUser() {
+        final Authentication authentication = randomFrom(
+            AuthenticationTestHelper.builder().apiKey().build(),
+            AuthenticationTestHelper.builder().serviceAccount().build()
+        );
+
+        assertThat(
+            AuthorizationDenialMessages.rolesDescription(authentication.getEffectiveSubject(), mock(AuthorizationInfo.class)),
+            equalTo("")
+        );
+    }
+
+    public void testRolesDescriptionWithNullAuthorizationInfo() {
+        // random 0 - 3 uniquely named roles
+        final List<String> assignedRoleNames = IntStream.range(0, randomIntBetween(0, 3))
+            .mapToObj(i -> randomAlphaOfLengthBetween(3, 8) + i)
+            .toList();
+        final Subject subject = AuthenticationTestHelper.builder()
+            .realm()
+            .user(new User(randomAlphaOfLengthBetween(3, 8), assignedRoleNames.toArray(String[]::new)))
+            .build(false)
+            .getEffectiveSubject();
+        final String rolesDescription = AuthorizationDenialMessages.rolesDescription(subject, null);
+
+        assertThat(
+            rolesDescription,
+            equalTo(" with assigned roles [%s]".formatted(Strings.collectionToCommaDelimitedString(assignedRoleNames)))
+        );
+    }
+
+    public void testRolesDescriptionWithNullRolesField() {
+        // random 0 - 3 uniquely named roles
+        final List<String> assignedRoleNames = IntStream.range(0, randomIntBetween(0, 3))
+            .mapToObj(i -> randomAlphaOfLengthBetween(3, 8) + i)
+            .toList();
+        final Subject subject = AuthenticationTestHelper.builder()
+            .realm()
+            .user(new User(randomAlphaOfLengthBetween(3, 8), assignedRoleNames.toArray(String[]::new)))
+            .build(false)
+            .getEffectiveSubject();
+        final AuthorizationInfo authorizationInfo = mock(AuthorizationInfo.class);
+        when(authorizationInfo.asMap()).thenReturn(Map.of());
+        final String rolesDescription = AuthorizationDenialMessages.rolesDescription(subject, authorizationInfo);
+
+        assertThat(
+            rolesDescription,
+            equalTo(" with assigned roles [%s]".formatted(Strings.collectionToCommaDelimitedString(assignedRoleNames)))
+        );
+    }
+
+    public void testRoleDescriptionWithEmptyResolvedRole() {
+        // random 0 - 3 uniquely named roles
+        final List<String> assignedRoleNames = IntStream.range(0, randomIntBetween(0, 3))
+            .mapToObj(i -> randomAlphaOfLengthBetween(3, 8) + i)
+            .toList();
+        final Subject subject = AuthenticationTestHelper.builder()
+            .realm()
+            .user(new User(randomAlphaOfLengthBetween(3, 8), assignedRoleNames.toArray(String[]::new)))
+            .build(false)
+            .getEffectiveSubject();
+
+        final RBACEngine.RBACAuthorizationInfo rbacAuthorizationInfo = mock(RBACEngine.RBACAuthorizationInfo.class);
+        when(rbacAuthorizationInfo.getRole()).thenReturn(Role.EMPTY);
+        final String rolesDescription = AuthorizationDenialMessages.rolesDescription(subject, rbacAuthorizationInfo);
+
+        if (assignedRoleNames.isEmpty()) {
+            assertThat(rolesDescription, equalTo(" with effective roles []"));
+        } else {
+            assertThat(
+                rolesDescription,
+                equalTo(
+                    " with effective roles [] (assigned roles [%s] were not found)".formatted(
+                        Strings.collectionToCommaDelimitedString(assignedRoleNames.stream().sorted().toList())
+                    )
+                )
+            );
+        }
+    }
+
+    public void testRoleDescriptionAllResolvedAndMaybeWithAnonymousRoles() {
+        // random 0 - 3 uniquely named roles
+        final List<String> assignedRoleNames = IntStream.range(0, randomIntBetween(0, 3))
+            .mapToObj(i -> randomAlphaOfLengthBetween(3, 8) + i)
+            .toList();
+        final Subject subject = AuthenticationTestHelper.builder()
+            .realm()
+            .user(new User(randomAlphaOfLengthBetween(3, 8), assignedRoleNames.toArray(String[]::new)))
+            .build(false)
+            .getEffectiveSubject();
+
+        // 0 - 2 anonymous roles
+        final List<String> anonymousRoleNames = randomList(2, () -> randomAlphaOfLength(10)).stream().toList();
+
+        // all roles
+        final List<String> effectiveRoleNames = Stream.concat(assignedRoleNames.stream(), anonymousRoleNames.stream()).toList();
+
+        final AuthorizationInfo authorizationInfo = mock(AuthorizationInfo.class);
+        when(authorizationInfo.asMap()).thenReturn(Map.of("user.roles", effectiveRoleNames.toArray(String[]::new)));
+
+        final String rolesDescription = AuthorizationDenialMessages.rolesDescription(subject, authorizationInfo);
+
+        assertThat(
+            rolesDescription,
+            equalTo(
+                " with effective roles [%s]".formatted(
+                    Strings.collectionToCommaDelimitedString(effectiveRoleNames.stream().sorted().toList())
+                )
+            )
+        );
+    }
+
+    public void testRoleDescriptionWithUnresolvedRoles() {
+        // random 1 - 3 uniquely named roles
+        final List<String> assignedRoleNames = IntStream.range(0, randomIntBetween(1, 3))
+            .mapToObj(i -> randomAlphaOfLengthBetween(3, 8) + i)
+            .toList();
+        final Subject subject = AuthenticationTestHelper.builder()
+            .realm()
+            .user(new User(randomAlphaOfLengthBetween(3, 8), assignedRoleNames.toArray(String[]::new)))
+            .build(false)
+            .getEffectiveSubject();
+
+        final List<String> unfoundedRoleNames = randomSubsetOf(randomIntBetween(1, assignedRoleNames.size()), assignedRoleNames);
+        final List<String> anonymousRoleNames = randomList(2, () -> randomAlphaOfLength(10)).stream().sorted().toList();
+
+        final List<String> effectiveRoleNames = Stream.concat(
+            assignedRoleNames.stream().filter(name -> false == unfoundedRoleNames.contains(name)),
+            anonymousRoleNames.stream()
+        ).toList();
+
+        final AuthorizationInfo authorizationInfo = mock(AuthorizationInfo.class);
+        when(authorizationInfo.asMap()).thenReturn(Map.of("user.roles", effectiveRoleNames.toArray(String[]::new)));
+
+        final String rolesDescription = AuthorizationDenialMessages.rolesDescription(subject, authorizationInfo);
+
+        assertThat(
+            rolesDescription,
+            equalTo(
+                " with effective roles [%s] (assigned roles [%s] were not found)".formatted(
+                    Strings.collectionToCommaDelimitedString(effectiveRoleNames.stream().sorted().toList()),
+                    Strings.collectionToCommaDelimitedString(unfoundedRoleNames.stream().sorted().toList())
+                )
+            )
+        );
+    }
+}

+ 20 - 13
x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/AuthorizationServiceTests.java

@@ -942,7 +942,13 @@ public class AuthorizationServiceTests extends ESTestCase {
         assertThat(
             securityException,
             throwableWithMessage(
-                containsString("[" + action + "] is unauthorized" + " for user [test user]" + " with roles [non-existent-role],")
+                containsString(
+                    "["
+                        + action
+                        + "] is unauthorized"
+                        + " for user [test user]"
+                        + " with effective roles [] (assigned roles [non-existent-role] were not found),"
+                )
             )
         );
         assertThat(securityException, throwableWithMessage(containsString("this action is granted by the index privileges [read,all]")));
@@ -1034,7 +1040,9 @@ public class AuthorizationServiceTests extends ESTestCase {
         );
         assertThat(
             securityException,
-            throwableWithMessage(containsString("[" + action + "] is unauthorized" + " for user [test user]" + " with roles [no_indices],"))
+            throwableWithMessage(
+                containsString("[" + action + "] is unauthorized" + " for user [test user]" + " with effective roles [no_indices],")
+            )
         );
         assertThat(securityException, throwableWithMessage(containsString("this action is granted by the index privileges [read,all]")));
 
@@ -1399,10 +1407,10 @@ public class AuthorizationServiceTests extends ESTestCase {
                         + " for user ["
                         + user.principal()
                         + "]"
-                        + " with roles ["
-                        + indexRole.getName()
-                        + ","
+                        + " with effective roles ["
                         + emptyRole.getName()
+                        + ","
+                        + indexRole.getName()
                         + "]"
                         + " on indices ["
                 )
@@ -1729,12 +1737,13 @@ public class AuthorizationServiceTests extends ESTestCase {
     public void testRunAsRequestWithNoRolesUser() {
         final TransportRequest request = mock(TransportRequest.class);
         final String requestId = AuditUtil.getOrGenerateRequestId(threadContext);
-        final Authentication authentication = createAuthentication(new User("run as me"), new User("test user", "admin"));
+        final User authenticatingUser = new User("test user");
+        final Authentication authentication = createAuthentication(new User("run as me"), authenticatingUser);
         assertNotEquals(authentication.getAuthenticatingSubject().getUser(), authentication.getEffectiveSubject().getUser());
         assertThrowsAuthorizationExceptionRunAsDenied(
             () -> authorize(authentication, "indices:a", request),
             "indices:a",
-            "test user",
+            authenticatingUser,
             "run as me"
         ); // run as [run as me]
         verify(auditTrail).runAsDenied(eq(requestId), eq(authentication), eq("indices:a"), eq(request), authzInfoRoles(Role.EMPTY.names()));
@@ -1756,7 +1765,7 @@ public class AuthorizationServiceTests extends ESTestCase {
         assertThrowsAuthorizationExceptionRunAsDenied(
             () -> authorize(authentication, AuthenticateAction.NAME, AuthenticateRequest.INSTANCE),
             AuthenticateAction.NAME,
-            "test user",
+            authUser,
             "run as me"
         ); // run as [run as me]
         verify(auditTrail).runAsDenied(
@@ -1771,10 +1780,8 @@ public class AuthorizationServiceTests extends ESTestCase {
 
     public void testRunAsRequestRunningAsUnAllowedUser() {
         TransportRequest request = mock(TransportRequest.class);
-        final Authentication authentication = createAuthentication(
-            new User("run as me", "doesn't exist"),
-            new User("test user", "can run as")
-        );
+        final User authenticatingUser = new User("test user", "can run as");
+        final Authentication authentication = createAuthentication(new User("run as me", "doesn't exist"), authenticatingUser);
         final RoleDescriptor role = new RoleDescriptor(
             "can run as",
             null,
@@ -1787,7 +1794,7 @@ public class AuthorizationServiceTests extends ESTestCase {
         assertThrowsAuthorizationExceptionRunAsDenied(
             () -> authorize(authentication, "indices:a", request),
             "indices:a",
-            "test user",
+            authenticatingUser,
             "run as me"
         );
         verify(auditTrail).runAsDenied(

+ 2 - 2
x-pack/plugin/src/yamlRestTest/resources/rest-api-spec/test/api_key/11_invalidation.yml

@@ -127,7 +127,7 @@ teardown:
             }
   - match: { "error.type": "security_exception" }
   - match:
-      "error.reason": "action [cluster:admin/xpack/security/api_key/invalidate] is unauthorized for user [api_key_user_1] with roles [user_role], this action is granted by the cluster privileges [manage_api_key,manage_security,all]"
+      "error.reason": "action [cluster:admin/xpack/security/api_key/invalidate] is unauthorized for user [api_key_user_1] with effective roles [user_role], this action is granted by the cluster privileges [manage_api_key,manage_security,all]"
 
   - do:
       headers:
@@ -191,7 +191,7 @@ teardown:
             }
   - match: { "error.type": "security_exception" }
   - match:
-      "error.reason": "action [cluster:admin/xpack/security/api_key/invalidate] is unauthorized for user [api_key_user_1] with roles [user_role], this action is granted by the cluster privileges [manage_api_key,manage_security,all]"
+      "error.reason": "action [cluster:admin/xpack/security/api_key/invalidate] is unauthorized for user [api_key_user_1] with effective roles [user_role], this action is granted by the cluster privileges [manage_api_key,manage_security,all]"
 
   - do:
       headers:

+ 3 - 3
x-pack/plugin/src/yamlRestTest/resources/rest-api-spec/test/api_key/12_grant.yml

@@ -239,7 +239,7 @@ teardown:
           }
   - match: { "error.type": "security_exception" }
   - match:
-      "error.reason": "action [cluster:admin/xpack/security/api_key/grant] is unauthorized for user [api_key_grant_target_user] with roles [api_key_grant_target_role], this action is granted by the cluster privileges [grant_api_key,manage_api_key,manage_security,all]"
+      "error.reason": "action [cluster:admin/xpack/security/api_key/grant] is unauthorized for user [api_key_grant_target_user] with effective roles [api_key_grant_target_role], this action is granted by the cluster privileges [grant_api_key,manage_api_key,manage_security,all]"
 
 ---
 "Test grant api key forbidden with manage_own_api_key privilege":
@@ -288,7 +288,7 @@ teardown:
           }
   - match: { "error.type": "security_exception" }
   - match:
-      "error.reason": "action [cluster:admin/xpack/security/api_key/grant] is unauthorized for user [api_key_grant_target_user] with roles [api_key_grant_target_role], this action is granted by the cluster privileges [grant_api_key,manage_api_key,manage_security,all]"
+      "error.reason": "action [cluster:admin/xpack/security/api_key/grant] is unauthorized for user [api_key_grant_target_user] with effective roles [api_key_grant_target_role], this action is granted by the cluster privileges [grant_api_key,manage_api_key,manage_security,all]"
 
   # Can't grant to others
   - do:
@@ -307,7 +307,7 @@ teardown:
           }
   - match: { "error.type": "security_exception" }
   - match:
-      "error.reason": "action [cluster:admin/xpack/security/api_key/grant] is unauthorized for user [api_key_grant_target_user] with roles [api_key_grant_target_role], this action is granted by the cluster privileges [grant_api_key,manage_api_key,manage_security,all]"
+      "error.reason": "action [cluster:admin/xpack/security/api_key/grant] is unauthorized for user [api_key_grant_target_user] with effective roles [api_key_grant_target_role], this action is granted by the cluster privileges [grant_api_key,manage_api_key,manage_security,all]"
 
   - do:
       security.clear_api_key_cache:

+ 2 - 1
x-pack/qa/runtime-fields/with-security/src/javaRestTest/java/org/elasticsearch/xpack/security/PermissionsIT.java

@@ -309,7 +309,8 @@ public class PermissionsIT extends ESRestTestCase {
             responseException.getMessage(),
             containsString(
                 "action [cluster:admin/scripts/painless/execute] is "
-                    + "unauthorized for user [test] with roles [test], this action is granted by the cluster privileges [manage,all]\"}]"
+                    + "unauthorized for user [test] with effective roles [test]"
+                    + ", this action is granted by the cluster privileges [manage,all]\"}]"
             )
         );
     }