Skip to content

Commit fd6604f

Browse files
authored
Dedicated internal user for cross cluster access (#94531)
This PR introduces a dedicated user `_cross_cluster_access` to perform internal orchestration actions for cross cluster operations under the configurable remote cluster security model. This improves the clarity of audit logs, error messages, and other contexts compared to re-using the system user. We perform the switch from a local system user to the `_cross_cluster_access` internal user on the querying cluster, when we intercept the outbound request. Semantically, this is the most sensible approach: the context of the cross cluster internal actions starts on the querying cluster. Instead of sending the internal user's predefined role descriptors, we let the fulfilling cluster resolve them. This makes the approach more robust w.r.t. future changes to the user permissions.
1 parent b2cf475 commit fd6604f

File tree

17 files changed

+316
-175
lines changed

17 files changed

+316
-175
lines changed

x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/Authentication.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
import org.elasticsearch.xpack.core.security.authz.RoleDescriptor;
3737
import org.elasticsearch.xpack.core.security.user.AnonymousUser;
3838
import org.elasticsearch.xpack.core.security.user.AsyncSearchUser;
39+
import org.elasticsearch.xpack.core.security.user.CrossClusterAccessUser;
3940
import org.elasticsearch.xpack.core.security.user.SecurityProfileUser;
4041
import org.elasticsearch.xpack.core.security.user.SystemUser;
4142
import org.elasticsearch.xpack.core.security.user.User;
@@ -1407,6 +1408,8 @@ private static User readUserWithoutTrailingBoolean(StreamInput input) throws IOE
14071408
return SecurityProfileUser.INSTANCE;
14081409
} else if (AsyncSearchUser.NAME.equals(username)) {
14091410
return AsyncSearchUser.INSTANCE;
1411+
} else if (CrossClusterAccessUser.NAME.equals(username)) {
1412+
return CrossClusterAccessUser.INSTANCE;
14101413
}
14111414
throw new IllegalStateException("username [" + username + "] does not match any internal user");
14121415
}
@@ -1431,6 +1434,8 @@ private static void writeInternalUser(User user, StreamOutput output) throws IOE
14311434
output.writeString(SecurityProfileUser.NAME);
14321435
} else if (AsyncSearchUser.is(user)) {
14331436
output.writeString(AsyncSearchUser.NAME);
1437+
} else if (CrossClusterAccessUser.is(user)) {
1438+
output.writeString(CrossClusterAccessUser.NAME);
14341439
} else {
14351440
assert false;
14361441
throw new IllegalStateException("user [" + user + "] is not internal");

x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/CrossClusterAccessSubjectInfo.java

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,6 @@ private static List<RoleDescriptorsBytes> toRoleDescriptorsBytesList(final RoleD
105105
throws IOException {
106106
// If we ever lift this restriction, we need to ensure that the serialization of each set of role descriptors to raw bytes is
107107
// deterministic. We can do so by sorting the role descriptors before serializing.
108-
109108
assert roleDescriptorsIntersection.roleDescriptorsList().stream().noneMatch(rds -> rds.size() > 1)
110109
: "sets with more than one role descriptor are not supported for cross cluster access authentication";
111110
final List<RoleDescriptorsBytes> roleDescriptorsBytesList = new ArrayList<>();
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
package org.elasticsearch.xpack.core.security.user;
9+
10+
import org.elasticsearch.TransportVersion;
11+
import org.elasticsearch.common.Strings;
12+
import org.elasticsearch.xpack.core.security.authc.Authentication;
13+
import org.elasticsearch.xpack.core.security.authc.CrossClusterAccessSubjectInfo;
14+
import org.elasticsearch.xpack.core.security.authz.RoleDescriptor;
15+
import org.elasticsearch.xpack.core.security.authz.RoleDescriptorsIntersection;
16+
17+
import java.io.IOException;
18+
import java.io.UncheckedIOException;
19+
20+
public class CrossClusterAccessUser extends User {
21+
public static final String NAME = UsernamesField.CROSS_CLUSTER_ACCESS_NAME;
22+
23+
private static final RoleDescriptor ROLE_DESCRIPTOR = new RoleDescriptor(
24+
UsernamesField.CROSS_CLUSTER_ACCESS_ROLE,
25+
new String[] { "cross_cluster_access" },
26+
null,
27+
null,
28+
null,
29+
null,
30+
null,
31+
null,
32+
null
33+
);
34+
35+
public static final User INSTANCE = new CrossClusterAccessUser();
36+
37+
private CrossClusterAccessUser() {
38+
super(NAME, Strings.EMPTY_ARRAY);
39+
// the following traits, and especially the run-as one, go with all the internal users
40+
// TODO abstract in a base `InternalUser` class
41+
assert enabled();
42+
assert roles() != null && roles().length == 0;
43+
}
44+
45+
@Override
46+
public boolean equals(Object o) {
47+
return INSTANCE == o;
48+
}
49+
50+
@Override
51+
public int hashCode() {
52+
return System.identityHashCode(this);
53+
}
54+
55+
public static boolean is(User user) {
56+
return INSTANCE.equals(user);
57+
}
58+
59+
public static CrossClusterAccessSubjectInfo subjectInfoWithRoleDescriptors(TransportVersion transportVersion, String nodeName) {
60+
return subjectInfo(transportVersion, nodeName, new RoleDescriptorsIntersection(ROLE_DESCRIPTOR));
61+
}
62+
63+
public static CrossClusterAccessSubjectInfo subjectInfoWithEmptyRoleDescriptors(TransportVersion transportVersion, String nodeName) {
64+
return subjectInfo(transportVersion, nodeName, RoleDescriptorsIntersection.EMPTY);
65+
}
66+
67+
private static CrossClusterAccessSubjectInfo subjectInfo(
68+
TransportVersion transportVersion,
69+
String nodeName,
70+
RoleDescriptorsIntersection roleDescriptorsIntersection
71+
) {
72+
try {
73+
return new CrossClusterAccessSubjectInfo(
74+
Authentication.newInternalAuthentication(INSTANCE, transportVersion, nodeName),
75+
roleDescriptorsIntersection
76+
);
77+
} catch (IOException e) {
78+
throw new UncheckedIOException(e);
79+
}
80+
}
81+
}

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,8 @@ public static boolean isInternal(User user) {
152152
|| XPackUser.is(user)
153153
|| XPackSecurityUser.is(user)
154154
|| SecurityProfileUser.is(user)
155-
|| AsyncSearchUser.is(user);
155+
|| AsyncSearchUser.is(user)
156+
|| CrossClusterAccessUser.is(user);
156157
}
157158

158159
/** Write the given {@link User} */

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ public final class UsernamesField {
1616
public static final String SYSTEM_ROLE = "_system";
1717
public static final String XPACK_SECURITY_NAME = "_xpack_security";
1818
public static final String XPACK_SECURITY_ROLE = "_xpack_security";
19+
public static final String CROSS_CLUSTER_ACCESS_NAME = "_cross_cluster_access";
20+
public static final String CROSS_CLUSTER_ACCESS_ROLE = "_cross_cluster_access";
1921
public static final String SECURITY_PROFILE_NAME = "_security_profile";
2022
public static final String SECURITY_PROFILE_ROLE = "_security_profile";
2123
public static final String XPACK_NAME = "_xpack";

x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/authc/AuthenticationTestHelper.java

Lines changed: 39 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
import org.elasticsearch.xpack.core.security.authz.RoleDescriptorsIntersection;
3131
import org.elasticsearch.xpack.core.security.user.AnonymousUser;
3232
import org.elasticsearch.xpack.core.security.user.AsyncSearchUser;
33+
import org.elasticsearch.xpack.core.security.user.CrossClusterAccessUser;
3334
import org.elasticsearch.xpack.core.security.user.SecurityProfileUser;
3435
import org.elasticsearch.xpack.core.security.user.SystemUser;
3536
import org.elasticsearch.xpack.core.security.user.User;
@@ -81,7 +82,8 @@ public class AuthenticationTestHelper {
8182
XPackUser.INSTANCE,
8283
XPackSecurityUser.INSTANCE,
8384
AsyncSearchUser.INSTANCE,
84-
SecurityProfileUser.INSTANCE
85+
SecurityProfileUser.INSTANCE,
86+
CrossClusterAccessUser.INSTANCE
8587
);
8688

8789
public static AuthenticationTestBuilder builder() {
@@ -234,35 +236,62 @@ public static String randomInternalRoleName() {
234236
UsernamesField.XPACK_ROLE,
235237
UsernamesField.ASYNC_SEARCH_ROLE,
236238
UsernamesField.XPACK_SECURITY_ROLE,
237-
UsernamesField.SECURITY_PROFILE_ROLE
239+
UsernamesField.SECURITY_PROFILE_ROLE,
240+
UsernamesField.CROSS_CLUSTER_ACCESS_ROLE
238241
);
239242
}
240243

241244
public static CrossClusterAccessSubjectInfo randomCrossClusterAccessSubjectInfo(
242245
RoleDescriptorsIntersection roleDescriptorsIntersection
243246
) {
244247
try {
245-
final Authentication authentication = randomCrossClusterAccessSupportedAuthenticationSubject();
248+
final Authentication authentication = randomCrossClusterAccessSupportedAuthenticationSubject(false);
246249
return new CrossClusterAccessSubjectInfo(authentication, roleDescriptorsIntersection);
247250
} catch (IOException e) {
248251
throw new UncheckedIOException(e);
249252
}
250253
}
251254

252-
private static Authentication randomCrossClusterAccessSupportedAuthenticationSubject() {
253-
return ESTestCase.randomFrom(
254-
AuthenticationTestHelper.builder().realm(),
255-
AuthenticationTestHelper.builder().internal(SystemUser.INSTANCE),
256-
AuthenticationTestHelper.builder().apiKey()
257-
).build();
255+
public static CrossClusterAccessSubjectInfo crossClusterAccessSubjectInfoForInternalUser(boolean emptyRoleDescriptors) {
256+
final Authentication authentication = AuthenticationTestHelper.builder().internal(CrossClusterAccessUser.INSTANCE).build();
257+
return emptyRoleDescriptors
258+
? CrossClusterAccessUser.subjectInfoWithEmptyRoleDescriptors(
259+
authentication.getEffectiveSubject().getTransportVersion(),
260+
authentication.getEffectiveSubject().getRealm().getNodeName()
261+
)
262+
: CrossClusterAccessUser.subjectInfoWithRoleDescriptors(
263+
authentication.getEffectiveSubject().getTransportVersion(),
264+
authentication.getEffectiveSubject().getRealm().getNodeName()
265+
);
266+
}
267+
268+
private static Authentication randomCrossClusterAccessSupportedAuthenticationSubject(boolean allowInternalUser) {
269+
final Set<String> allowedTypes = new HashSet<>(Set.of("realm", "apikey"));
270+
if (allowInternalUser) {
271+
allowedTypes.add("internal");
272+
}
273+
final String type = ESTestCase.randomFrom(allowedTypes.toArray(new String[0]));
274+
return switch (type) {
275+
case "realm" -> AuthenticationTestHelper.builder().realm().build();
276+
case "apikey" -> AuthenticationTestHelper.builder().apiKey().build();
277+
case "internal" -> AuthenticationTestHelper.builder().internal(CrossClusterAccessUser.INSTANCE).build();
278+
default -> throw new UnsupportedOperationException("unknown type " + type);
279+
};
258280
}
259281

260282
public static CrossClusterAccessSubjectInfo randomCrossClusterAccessSubjectInfo() {
261-
final Authentication authentication = randomCrossClusterAccessSupportedAuthenticationSubject();
283+
return randomCrossClusterAccessSubjectInfo(true);
284+
}
285+
286+
public static CrossClusterAccessSubjectInfo randomCrossClusterAccessSubjectInfo(boolean allowInternalUser) {
287+
final Authentication authentication = randomCrossClusterAccessSupportedAuthenticationSubject(allowInternalUser);
262288
return randomCrossClusterAccessSubjectInfo(authentication);
263289
}
264290

265291
public static CrossClusterAccessSubjectInfo randomCrossClusterAccessSubjectInfo(final Authentication authentication) {
292+
if (CrossClusterAccessUser.is(authentication.getEffectiveSubject().getUser())) {
293+
return crossClusterAccessSubjectInfoForInternalUser(false);
294+
}
266295
final int numberOfRoleDescriptors;
267296
if (authentication.isApiKey()) {
268297
// In case of API keys, we can have either 1 (only owner's - aka limited-by) or 2 role descriptors.

x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/authc/AuthenticationTests.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ public void testCanAccessResourcesOf() {
140140

141141
public void testCrossClusterAccessCanAccessResourceOf() throws IOException {
142142
final String apiKeyId1 = randomAlphaOfLengthBetween(10, 20);
143-
final CrossClusterAccessSubjectInfo crossClusterAccessSubjectInfo1 = randomCrossClusterAccessSubjectInfo(
143+
final CrossClusterAccessSubjectInfo crossClusterAccessSubjectInfo1 = AuthenticationTestHelper.randomCrossClusterAccessSubjectInfo(
144144
AuthenticationTestHelper.builder().realm().build()
145145
);
146146
final Authentication authentication = AuthenticationTestHelper.builder()

x-pack/plugin/security/qa/security-trial/src/javaRestTest/java/org/elasticsearch/xpack/security/crossclusteraccess/CrossClusterAccessHeadersForCcsRestIT.java

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@
4949
import org.elasticsearch.xpack.core.security.authz.RoleDescriptor;
5050
import org.elasticsearch.xpack.core.security.authz.RoleDescriptorsIntersection;
5151
import org.elasticsearch.xpack.core.security.authz.permission.Role;
52-
import org.elasticsearch.xpack.core.security.user.SystemUser;
52+
import org.elasticsearch.xpack.core.security.user.CrossClusterAccessUser;
5353
import org.elasticsearch.xpack.core.security.user.User;
5454
import org.elasticsearch.xpack.security.SecurityOnTrialLicenseRestTestCase;
5555
import org.elasticsearch.xpack.security.authc.ApiKeyService;
@@ -942,23 +942,19 @@ private void expectActionsAndHeadersForCluster(
942942
);
943943
for (CapturedActionWithHeaders actual : actualActionsWithHeaders) {
944944
switch (actual.action) {
945-
// this action is run by the system user, so we expect a cross cluster access header with an internal user authentication
946-
// and empty role descriptors intersection
945+
// this action is run by the cross cluster access user, so we expect a cross cluster access header with an internal user
946+
// authentication and pre-defined role descriptors intersection
947947
case RemoteClusterNodesAction.NAME -> {
948948
assertContainsCrossClusterAccessHeaders(actual.headers());
949949
assertContainsCrossClusterAccessCredentialsHeader(encodedCredential, actual);
950950
final var actualCrossClusterAccessSubjectInfo = CrossClusterAccessSubjectInfo.decode(
951951
actual.headers().get(CrossClusterAccessSubjectInfo.CROSS_CLUSTER_ACCESS_SUBJECT_INFO_HEADER_KEY)
952952
);
953-
final var expectedCrossClusterAccessSubjectInfo = new CrossClusterAccessSubjectInfo(
954-
Authentication.newInternalAuthentication(
955-
SystemUser.INSTANCE,
956-
TransportVersion.CURRENT,
957-
// Since we are running on a multi-node cluster the actual node name may be different between runs
958-
// so just copy the one from the actual result
959-
actualCrossClusterAccessSubjectInfo.getAuthentication().getEffectiveSubject().getRealm().getNodeName()
960-
),
961-
RoleDescriptorsIntersection.EMPTY
953+
final var expectedCrossClusterAccessSubjectInfo = CrossClusterAccessUser.subjectInfoWithEmptyRoleDescriptors(
954+
TransportVersion.CURRENT,
955+
// Since we are running on a multi-node cluster the actual node name may be different between runs
956+
// so just copy the one from the actual result
957+
actualCrossClusterAccessSubjectInfo.getAuthentication().getEffectiveSubject().getRealm().getNodeName()
962958
);
963959
assertThat(actualCrossClusterAccessSubjectInfo, equalTo(expectedCrossClusterAccessSubjectInfo));
964960
}

x-pack/plugin/security/src/internalClusterTest/java/org/elasticsearch/integration/InternalUserAndRoleIntegTests.java

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import org.elasticsearch.xpack.core.XPackPlugin;
1515
import org.elasticsearch.xpack.core.security.authc.support.Hasher;
1616
import org.elasticsearch.xpack.core.security.user.AsyncSearchUser;
17+
import org.elasticsearch.xpack.core.security.user.CrossClusterAccessUser;
1718
import org.elasticsearch.xpack.core.security.user.SecurityProfileUser;
1819
import org.elasticsearch.xpack.core.security.user.SystemUser;
1920
import org.elasticsearch.xpack.core.security.user.UsernamesField;
@@ -30,14 +31,16 @@ public class InternalUserAndRoleIntegTests extends AbstractPrivilegeTestCase {
3031
XPackUser.NAME,
3132
XPackSecurityUser.NAME,
3233
AsyncSearchUser.NAME,
33-
SecurityProfileUser.NAME };
34+
SecurityProfileUser.NAME,
35+
CrossClusterAccessUser.NAME };
3436

3537
private static final String[] INTERNAL_ROLE_NAMES = new String[] {
3638
UsernamesField.SYSTEM_ROLE,
3739
UsernamesField.XPACK_ROLE,
3840
UsernamesField.XPACK_SECURITY_ROLE,
3941
UsernamesField.ASYNC_SEARCH_ROLE,
40-
UsernamesField.SECURITY_PROFILE_ROLE };
42+
UsernamesField.SECURITY_PROFILE_ROLE,
43+
UsernamesField.CROSS_CLUSTER_ACCESS_ROLE };
4144
public static final String NON_INTERNAL_USERNAME = "user";
4245
public static final String NON_INTERNAL_ROLE_NAME = "role";
4346

0 commit comments

Comments
 (0)