Skip to content

Commit 725367e

Browse files
authored
User Profile - Detailed errors in hasPrivileges response (#89224)
This PR adds a new `errors` field in the ProfilehasPrivileges response to report detailed errors encountered, including missing UIDs. It also removes the existing `errors_uids` field since this is redundant after the change.
1 parent 3bde177 commit 725367e

File tree

11 files changed

+203
-140
lines changed

11 files changed

+203
-140
lines changed

docs/changelog/89224.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
pr: 89224
2+
summary: User Profile - Detailed errors in `hasPrivileges` response
3+
area: Security
4+
type: enhancement
5+
issues: []

x-pack/docs/en/rest-api/security/has-privileges-user-profile.asciidoc

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -68,14 +68,21 @@ Note that the `privileges` section above is identical to the
6868
==== {api-response-body-title}
6969

7070
A successful has privileges user profile API call returns a JSON structure that contains
71-
two list fields:
71+
two fields:
7272

7373
`has_privilege_uids`:: (list) The subset of the requested profile IDs of the users that have
7474
**all** the requested privileges.
7575

76-
`error_uids`:: (list) The subset of the requested profile IDs for which an error was
77-
encountered. It does **not** include the missing profile IDs or the profile IDs of
78-
the users that do not have all the requested privileges. This field is absent if empty.
76+
`errors`:: (object) Errors encountered while fulfilling the request. This field is absent if there is no error.
77+
It does **not** include the profile IDs of the users that do not have all the requested privileges.
78+
+
79+
.Properties of objects in `errors`
80+
[%collapsible%open]
81+
====
82+
`count`:: (number) Total number of errors
83+
84+
`details`:: (object) The detailed error report with keys being profile IDs and values being the exact errors.
85+
====
7986

8087
[[security-api-has-privileges-user-profile-example]]
8188
==== {api-examples-title}
@@ -87,7 +94,11 @@ requested set of cluster, index, and application privileges:
8794
--------------------------------------------------
8895
POST /_security/user/_has_privileges
8996
{
90-
"uids": ["u_LQPnxDxEjIH0GOUoFkZr5Y57YUwSkL9Joiq-g4OCbPc_0", "u_rzRnxDgEHIH0GOUoFkZr5Y27YUwSk19Joiq=g4OCxxB_1"],
97+
"uids": [
98+
"u_LQPnxDxEjIH0GOUoFkZr5Y57YUwSkL9Joiq-g4OCbPc_0",
99+
"u_rzRnxDgEHIH0GOUoFkZr5Y27YUwSk19Joiq=g4OCxxB_1",
100+
"u_does-not-exist_0"
101+
],
91102
"cluster": [ "monitor", "create_snapshot", "manage_ml" ],
92103
"index" : [
93104
{
@@ -110,12 +121,22 @@ POST /_security/user/_has_privileges
110121
--------------------------------------------------
111122
// TEST[skip:TODO setup and tests will be possible once the profile uid is predictable]
112123

113-
The following example output indicates that only one of the two users has all the privileges:
124+
The following example output indicates that only one of the three users has all the privileges
125+
and one of them is not found:
114126

115127
[source,js]
116128
--------------------------------------------------
117129
{
118-
"has_privilege_uids": ["u_rzRnxDgEHIH0GOUoFkZr5Y27YUwSk19Joiq=g4OCxxB_1"]
130+
"has_privilege_uids": ["u_rzRnxDgEHIH0GOUoFkZr5Y27YUwSk19Joiq=g4OCxxB_1"],
131+
"errors": {
132+
"count": 1,
133+
"details": {
134+
"u_does-not-exist_0": {
135+
"type": "resource_not_found_exception",
136+
"reason": "profile document not found"
137+
}
138+
}
139+
}
119140
}
120141
--------------------------------------------------
121142
// NOTCONSOLE

x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/user/ProfileHasPrivilegesResponse.java

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -12,66 +12,68 @@
1212
import org.elasticsearch.common.io.stream.StreamOutput;
1313
import org.elasticsearch.xcontent.ToXContentObject;
1414
import org.elasticsearch.xcontent.XContentBuilder;
15+
import org.elasticsearch.xpack.core.security.xcontent.XContentUtils;
1516

1617
import java.io.IOException;
18+
import java.util.Map;
1719
import java.util.Objects;
1820
import java.util.Set;
1921

2022
public class ProfileHasPrivilegesResponse extends ActionResponse implements ToXContentObject {
2123

2224
private Set<String> hasPrivilegeUids;
23-
private Set<String> errorUids;
25+
private final Map<String, Exception> errors;
2426

2527
public ProfileHasPrivilegesResponse(StreamInput in) throws IOException {
2628
super(in);
2729
this.hasPrivilegeUids = in.readSet(StreamInput::readString);
28-
this.errorUids = in.readSet(StreamInput::readString);
30+
this.errors = in.readMap(StreamInput::readString, StreamInput::readException);
2931
}
3032

31-
public ProfileHasPrivilegesResponse(Set<String> hasPrivilegeUids, Set<String> errorUids) {
33+
public ProfileHasPrivilegesResponse(Set<String> hasPrivilegeUids, Map<String, Exception> errors) {
3234
super();
3335
this.hasPrivilegeUids = Objects.requireNonNull(hasPrivilegeUids);
34-
this.errorUids = Objects.requireNonNull(errorUids);
36+
this.errors = Objects.requireNonNull(errors);
3537
}
3638

3739
public Set<String> hasPrivilegeUids() {
3840
return hasPrivilegeUids;
3941
}
4042

41-
public Set<String> errorUids() {
42-
return errorUids;
43+
public Map<String, Exception> errors() {
44+
return errors;
4345
}
4446

4547
@Override
4648
public boolean equals(Object o) {
4749
if (this == o) return true;
4850
if (o == null || getClass() != o.getClass()) return false;
4951
ProfileHasPrivilegesResponse that = (ProfileHasPrivilegesResponse) o;
50-
return hasPrivilegeUids.equals(that.hasPrivilegeUids) && errorUids.equals(that.errorUids);
52+
// Only compare the keys (profile uids) of the errors, actual error types do not matter
53+
return hasPrivilegeUids.equals(that.hasPrivilegeUids) && errors.keySet().equals(that.errors.keySet());
5154
}
5255

5356
@Override
5457
public int hashCode() {
55-
return Objects.hash(hasPrivilegeUids, errorUids);
58+
// Only include the keys (profile uids) of the errors, actual error types do not matter
59+
return Objects.hash(hasPrivilegeUids, errors.keySet());
5660
}
5761

5862
@Override
5963
public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException {
6064
XContentBuilder xContentBuilder = builder.startObject().stringListField("has_privilege_uids", hasPrivilegeUids);
61-
if (false == errorUids.isEmpty()) {
62-
xContentBuilder.stringListField("error_uids", errorUids);
63-
}
65+
XContentUtils.maybeAddErrorDetails(builder, errors);
6466
return xContentBuilder.endObject();
6567
}
6668

6769
@Override
6870
public void writeTo(StreamOutput out) throws IOException {
6971
out.writeStringCollection(hasPrivilegeUids);
70-
out.writeStringCollection(errorUids);
72+
out.writeMap(errors, StreamOutput::writeString, StreamOutput::writeException);
7173
}
7274

7375
@Override
7476
public String toString() {
75-
return getClass().getSimpleName() + "{" + "has_privilege_uids=" + hasPrivilegeUids + ", error_uids=" + errorUids + "}";
77+
return getClass().getSimpleName() + "{" + "has_privilege_uids=" + hasPrivilegeUids + ", errors=" + errors + "}";
7678
}
7779
}

x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/profile/ProfileHasPrivilegesResponseTests.java

Lines changed: 65 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,22 +7,30 @@
77

88
package org.elasticsearch.xpack.core.security.action.profile;
99

10+
import org.elasticsearch.ElasticsearchException;
11+
import org.elasticsearch.ResourceNotFoundException;
1012
import org.elasticsearch.common.bytes.BytesReference;
1113
import org.elasticsearch.common.io.stream.Writeable;
1214
import org.elasticsearch.common.xcontent.XContentHelper;
1315
import org.elasticsearch.test.AbstractWireSerializingTestCase;
1416
import org.elasticsearch.xcontent.ToXContent;
1517
import org.elasticsearch.xcontent.XContentBuilder;
1618
import org.elasticsearch.xcontent.XContentFactory;
19+
import org.elasticsearch.xcontent.json.JsonXContent;
1720
import org.elasticsearch.xpack.core.security.action.user.ProfileHasPrivilegesResponse;
1821

1922
import java.io.IOException;
2023
import java.util.ArrayList;
2124
import java.util.HashSet;
25+
import java.util.List;
2226
import java.util.Map;
2327
import java.util.Set;
28+
import java.util.TreeMap;
29+
import java.util.function.Supplier;
30+
import java.util.stream.IntStream;
2431

2532
import static org.hamcrest.Matchers.equalTo;
33+
import static org.hamcrest.Matchers.hasEntry;
2634

2735
public class ProfileHasPrivilegesResponseTests extends AbstractWireSerializingTestCase<ProfileHasPrivilegesResponse> {
2836

@@ -35,16 +43,19 @@ protected Writeable.Reader<ProfileHasPrivilegesResponse> instanceReader() {
3543
protected ProfileHasPrivilegesResponse createTestInstance() {
3644
return new ProfileHasPrivilegesResponse(
3745
randomUnique(() -> randomAlphaOfLengthBetween(0, 5), randomIntBetween(0, 5)),
38-
randomUnique(() -> randomAlphaOfLengthBetween(0, 5), randomIntBetween(0, 5))
46+
randomErrors()
3947
);
4048
}
4149

4250
@Override
4351
protected ProfileHasPrivilegesResponse mutateInstance(ProfileHasPrivilegesResponse instance) throws IOException {
4452
return randomFrom(
45-
new ProfileHasPrivilegesResponse(newMutatedSet(instance.hasPrivilegeUids()), instance.errorUids()),
46-
new ProfileHasPrivilegesResponse(instance.hasPrivilegeUids(), newMutatedSet(instance.errorUids())),
47-
new ProfileHasPrivilegesResponse(newMutatedSet(instance.hasPrivilegeUids()), newMutatedSet(instance.errorUids()))
53+
new ProfileHasPrivilegesResponse(newMutatedSet(instance.hasPrivilegeUids()), instance.errors()),
54+
new ProfileHasPrivilegesResponse(instance.hasPrivilegeUids(), randomValueOtherThan(instance.errors(), this::randomErrors)),
55+
new ProfileHasPrivilegesResponse(
56+
newMutatedSet(instance.hasPrivilegeUids()),
57+
randomValueOtherThan(instance.errors(), this::randomErrors)
58+
)
4859
);
4960
}
5061

@@ -55,20 +66,47 @@ public void testToXContent() throws IOException {
5566
final Map<String, Object> responseMap = XContentHelper.convertToMap(BytesReference.bytes(builder), false, builder.contentType())
5667
.v2();
5768

58-
if (response.errorUids().isEmpty()) {
69+
if (response.errors().isEmpty()) {
5970
assertThat(responseMap, equalTo(Map.of("has_privilege_uids", new ArrayList<>(response.hasPrivilegeUids()))));
6071
} else {
61-
assertThat(
62-
responseMap,
63-
equalTo(
64-
Map.of(
65-
"has_privilege_uids",
66-
new ArrayList<>(response.hasPrivilegeUids()),
67-
"error_uids",
68-
new ArrayList<>(response.errorUids())
69-
)
70-
)
71-
);
72+
assertThat(responseMap, hasEntry("has_privilege_uids", List.copyOf(response.hasPrivilegeUids())));
73+
@SuppressWarnings("unchecked")
74+
final Map<String, Object> errorsMap = (Map<String, Object>) responseMap.get("errors");
75+
assertThat(errorsMap.get("count"), equalTo(response.errors().size()));
76+
@SuppressWarnings("unchecked")
77+
final Map<String, Object> detailsMap = (Map<String, Object>) errorsMap.get("details");
78+
assertThat(detailsMap.keySet(), equalTo(response.errors().keySet()));
79+
80+
detailsMap.forEach((k, v) -> {
81+
final String errorString;
82+
final Exception e = response.errors().get(k);
83+
if (e instanceof IllegalArgumentException illegalArgumentException) {
84+
errorString = """
85+
{
86+
"type": "illegal_argument_exception",
87+
"reason": "%s"
88+
}""".formatted(illegalArgumentException.getMessage());
89+
} else if (e instanceof ResourceNotFoundException resourceNotFoundException) {
90+
errorString = """
91+
{
92+
"type": "resource_not_found_exception",
93+
"reason": "%s"
94+
}""".formatted(resourceNotFoundException.getMessage());
95+
} else if (e instanceof ElasticsearchException elasticsearchException) {
96+
errorString = """
97+
{
98+
"type": "exception",
99+
"reason": "%s",
100+
"caused_by": {
101+
"type": "illegal_argument_exception",
102+
"reason": "%s"
103+
}
104+
}""".formatted(elasticsearchException.getMessage(), elasticsearchException.getCause().getMessage());
105+
} else {
106+
throw new IllegalArgumentException("unknown exception type: " + e);
107+
}
108+
assertThat(v, equalTo(XContentHelper.convertToMap(JsonXContent.jsonXContent, errorString, false)));
109+
});
72110
}
73111
}
74112

@@ -86,4 +124,15 @@ private Set<String> newMutatedSet(Set<String> in) {
86124
}
87125
return mutated;
88126
}
127+
128+
private Map<String, Exception> randomErrors() {
129+
final Map<String, Exception> errors = new TreeMap<>();
130+
final Supplier<Exception> randomExceptionSupplier = () -> randomFrom(
131+
new IllegalArgumentException(randomAlphaOfLengthBetween(0, 18)),
132+
new ResourceNotFoundException(randomAlphaOfLengthBetween(0, 18)),
133+
new ElasticsearchException(randomAlphaOfLengthBetween(0, 18), new IllegalArgumentException(randomAlphaOfLengthBetween(0, 18)))
134+
);
135+
IntStream.range(0, randomIntBetween(0, 3)).forEach(i -> errors.put(randomAlphaOfLength(20) + i, randomExceptionSupplier.get()));
136+
return errors;
137+
}
89138
}

x-pack/plugin/security/qa/profile/src/javaRestTest/java/org/elasticsearch/xpack/security/profile/ProfileIT.java

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,8 +115,19 @@ public void testProfileHasPrivileges() throws IOException {
115115
final Response profileHasPrivilegesResponse = adminClient().performRequest(profileHasPrivilegesRequest);
116116
assertOK(profileHasPrivilegesResponse);
117117
Map<String, Object> profileHasPrivilegesResponseMap = responseAsMap(profileHasPrivilegesResponse);
118-
assertThat(profileHasPrivilegesResponseMap.keySet(), contains("has_privilege_uids"));
118+
assertThat(profileHasPrivilegesResponseMap.keySet(), contains("has_privilege_uids", "errors"));
119119
assertThat(((List<String>) profileHasPrivilegesResponseMap.get("has_privilege_uids")), contains(profileUid));
120+
assertThat(
121+
profileHasPrivilegesResponseMap.get("errors"),
122+
equalTo(
123+
Map.of(
124+
"count",
125+
1,
126+
"details",
127+
Map.of("some_missing_profile", Map.of("type", "resource_not_found_exception", "reason", "profile document not found"))
128+
)
129+
)
130+
);
120131
}
121132

122133
public void testGetProfiles() throws IOException {

x-pack/plugin/security/src/internalClusterTest/java/org/elasticsearch/xpack/security/profile/ProfileIntegTests.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -586,7 +586,7 @@ public void testProfileAPIsWhenIndexNotCreated() {
586586
)
587587
).actionGet();
588588
assertThat(profileHasPrivilegesResponse.hasPrivilegeUids(), emptyIterable());
589-
assertThat(profileHasPrivilegesResponse.errorUids(), emptyIterable());
589+
assertThat(profileHasPrivilegesResponse.errors(), anEmptyMap());
590590

591591
// Ensure index does not exist
592592
assertThat(getProfileIndexResponse().getIndices(), not(hasItemInArray(INTERNAL_SECURITY_PROFILE_INDEX_8)));
@@ -650,7 +650,7 @@ public void testSetEnabled() {
650650
)
651651
).actionGet();
652652
assertThat(profileHasPrivilegesResponse.hasPrivilegeUids(), emptyIterable());
653-
assertThat(profileHasPrivilegesResponse.errorUids(), emptyIterable());
653+
assertThat(profileHasPrivilegesResponse.errors(), anEmptyMap());
654654

655655
// Enable again for search
656656
final SetProfileEnabledRequest setProfileEnabledRequest2 = new SetProfileEnabledRequest(

x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/profile/TransportProfileHasPrivilegesAction.java

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
import java.util.HashSet;
3737
import java.util.Map;
3838
import java.util.Set;
39+
import java.util.concurrent.ConcurrentHashMap;
3940
import java.util.concurrent.atomic.AtomicInteger;
4041
import java.util.stream.Collectors;
4142

@@ -75,20 +76,18 @@ public TransportProfileHasPrivilegesAction(
7576
protected void doExecute(Task task, ProfileHasPrivilegesRequest request, ActionListener<ProfileHasPrivilegesResponse> listener) {
7677
assert task instanceof CancellableTask : "task must be cancellable";
7778
profileService.getProfileSubjects(request.profileUids(), ActionListener.wrap(profileSubjectsAndFailures -> {
78-
if (profileSubjectsAndFailures.profileUidToSubject().isEmpty()) {
79-
listener.onResponse(new ProfileHasPrivilegesResponse(Set.of(), profileSubjectsAndFailures.failureProfileUids()));
79+
if (profileSubjectsAndFailures.results().isEmpty()) {
80+
listener.onResponse(new ProfileHasPrivilegesResponse(Set.of(), profileSubjectsAndFailures.errors()));
8081
return;
8182
}
8283
final Set<String> hasPrivilegeProfiles = Collections.synchronizedSet(new HashSet<>());
83-
final Set<String> errorProfiles = Collections.synchronizedSet(new HashSet<>(profileSubjectsAndFailures.failureProfileUids()));
84-
final Collection<Map.Entry<String, Subject>> profileUidAndSubjects = profileSubjectsAndFailures.profileUidToSubject()
85-
.entrySet();
86-
final AtomicInteger counter = new AtomicInteger(profileUidAndSubjects.size());
84+
final Map<String, Exception> errorProfiles = new ConcurrentHashMap<>(profileSubjectsAndFailures.errors());
85+
final AtomicInteger counter = new AtomicInteger(profileSubjectsAndFailures.results().size());
8786
assert counter.get() > 0;
8887
resolveApplicationPrivileges(
8988
request,
9089
ActionListener.wrap(applicationPrivilegeDescriptors -> threadPool.generic().execute(() -> {
91-
for (Map.Entry<String, Subject> profileUidToSubject : profileUidAndSubjects) {
90+
for (Map.Entry<String, Subject> profileUidToSubject : profileSubjectsAndFailures.results()) {
9291
// return the partial response if the "has privilege" task got cancelled in the meantime
9392
if (((CancellableTask) task).isCancelled()) {
9493
listener.onFailure(new TaskCancelledException("has privilege task cancelled"));
@@ -107,7 +106,7 @@ protected void doExecute(Task task, ProfileHasPrivilegesRequest request, ActionL
107106
}
108107
}, checkPrivilegesException -> {
109108
logger.debug(() -> "Failed to check privileges for profile [" + profileUid + "]", checkPrivilegesException);
110-
errorProfiles.add(profileUid);
109+
errorProfiles.put(profileUid, checkPrivilegesException);
111110
}), () -> {
112111
if (counter.decrementAndGet() == 0) {
113112
listener.onResponse(new ProfileHasPrivilegesResponse(hasPrivilegeProfiles, errorProfiles));

0 commit comments

Comments
 (0)