Skip to content

Commit 8761f39

Browse files
authored
Expose cluster-state role mappings in APIs (#114951) (#115387)
This PR exposes operator-defined, cluster-state role mappings in the [Get role mappings API](https://www.elastic.co/guide/en/elasticsearch/reference/current/security-api-get-role-mapping.html). Cluster-state role mappings are returned with a reserved suffix `-read-only-operator-mapping`, to disambiguate with native role mappings stored in the security index. CS role mappings are also marked with a `_read_only` metadata flag. It's possible to query a CS role mapping using its name both with and without the suffix. CS role mappings can be viewed via the API, but cannot be modified. To clarify this, the PUT and DELETE role mapping endpoints return header warnings if native role mappings that name-clash with CS role mappings are created, modified, or deleted. The PR also prevents the creation or role mappings with names ending in `-read-only-operator-mapping` to ensure that CS role mappings and native role mappings can always be fully disambiguated. Finally, the PR changes how CS role mappings are persisted in cluster-state. CS role mappings are written (and read from disk) in the `XContent` format. This format omits the role mapping's name. This means that if CS role mappings are ever recovered from disk (e.g., during a master-node restart), their names are erased. To address this, this PR changes CS role mapping serialization to persist the name of a mapping in a reserved metadata field, and recover it from metadata during serialization. This allows us to persist the name without BWC-breaks in role mapping `XContent` format. It also allows us to ensure that role mappings are re-written to cluster state in the new, name-preserving format the first time operator file settings are processed. Depends on: #114295 Relates: ES-9628
1 parent d1b839e commit 8761f39

File tree

16 files changed

+962
-145
lines changed

16 files changed

+962
-145
lines changed

docs/changelog/114951.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
pr: 114951
2+
summary: Expose cluster-state role mappings in APIs
3+
area: Authentication
4+
type: bug
5+
issues: []

qa/rolling-upgrade/src/javaRestTest/java/org/elasticsearch/upgrades/FileSettingsRoleMappingUpgradeIT.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,12 @@
2525

2626
import java.io.IOException;
2727
import java.util.List;
28+
import java.util.Map;
2829
import java.util.function.Supplier;
2930

31+
import static org.hamcrest.Matchers.contains;
3032
import static org.hamcrest.Matchers.equalTo;
33+
import static org.hamcrest.Matchers.instanceOf;
3134
import static org.hamcrest.Matchers.is;
3235
import static org.hamcrest.Matchers.not;
3336
import static org.hamcrest.Matchers.nullValue;
@@ -106,6 +109,10 @@ public void testRoleMappingsAppliedOnUpgrade() throws IOException {
106109
);
107110
assertThat(roleMappings, is(not(nullValue())));
108111
assertThat(roleMappings.size(), equalTo(1));
112+
assertThat(roleMappings, is(instanceOf(Map.class)));
113+
@SuppressWarnings("unchecked")
114+
Map<String, Object> roleMapping = (Map<String, Object>) roleMappings;
115+
assertThat(roleMapping.keySet(), contains("everyone_kibana-read-only-operator-mapping"));
109116
}
110117
}
111118
}

x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/support/mapper/ExpressionRoleMapping.java

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,18 @@
5454
*/
5555
public class ExpressionRoleMapping implements ToXContentObject, Writeable {
5656

57+
/**
58+
* Reserved suffix for read-only operator-defined role mappings.
59+
* This suffix is added to the name of all cluster-state role mappings returned via
60+
* the {@code TransportGetRoleMappingsAction} action.
61+
*/
62+
public static final String READ_ONLY_ROLE_MAPPING_SUFFIX = "-read-only-operator-mapping";
63+
/**
64+
* Reserved metadata field to mark role mappings as read-only.
65+
* This field is added to the metadata of all cluster-state role mappings returned via
66+
* the {@code TransportGetRoleMappingsAction} action.
67+
*/
68+
public static final String READ_ONLY_ROLE_MAPPING_METADATA_FLAG = "_read_only";
5769
private static final ObjectParser<Builder, String> PARSER = new ObjectParser<>("role-mapping", Builder::new);
5870

5971
/**
@@ -136,6 +148,28 @@ public ExpressionRoleMapping(StreamInput in) throws IOException {
136148
this.metadata = in.readGenericMap();
137149
}
138150

151+
public static boolean hasReadOnlySuffix(String name) {
152+
return name.endsWith(READ_ONLY_ROLE_MAPPING_SUFFIX);
153+
}
154+
155+
public static void validateNoReadOnlySuffix(String name) {
156+
if (hasReadOnlySuffix(name)) {
157+
throw new IllegalArgumentException(
158+
"Invalid mapping name [" + name + "]. [" + READ_ONLY_ROLE_MAPPING_SUFFIX + "] is not an allowed suffix"
159+
);
160+
}
161+
}
162+
163+
public static String addReadOnlySuffix(String name) {
164+
return name + READ_ONLY_ROLE_MAPPING_SUFFIX;
165+
}
166+
167+
public static String removeReadOnlySuffixIfPresent(String name) {
168+
return name.endsWith(READ_ONLY_ROLE_MAPPING_SUFFIX)
169+
? name.substring(0, name.length() - READ_ONLY_ROLE_MAPPING_SUFFIX.length())
170+
: name;
171+
}
172+
139173
@Override
140174
public void writeTo(StreamOutput out) throws IOException {
141175
out.writeString(name);

x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/RoleMappingMetadata.java

Lines changed: 69 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77

88
package org.elasticsearch.xpack.core.security.authz;
99

10+
import org.apache.logging.log4j.LogManager;
11+
import org.apache.logging.log4j.Logger;
1012
import org.elasticsearch.TransportVersion;
1113
import org.elasticsearch.TransportVersions;
1214
import org.elasticsearch.cluster.AbstractNamedDiffable;
@@ -26,8 +28,10 @@
2628
import java.io.IOException;
2729
import java.util.Collection;
2830
import java.util.EnumSet;
31+
import java.util.HashMap;
2932
import java.util.Iterator;
3033
import java.util.LinkedHashSet;
34+
import java.util.Map;
3135
import java.util.Objects;
3236
import java.util.Set;
3337

@@ -36,7 +40,11 @@
3640

3741
public final class RoleMappingMetadata extends AbstractNamedDiffable<Metadata.Custom> implements Metadata.Custom {
3842

43+
private static final Logger logger = LogManager.getLogger(RoleMappingMetadata.class);
44+
3945
public static final String TYPE = "role_mappings";
46+
public static final String METADATA_NAME_FIELD = "_es_reserved_role_mapping_name";
47+
public static final String FALLBACK_NAME = "name_not_available_after_deserialization";
4048

4149
@SuppressWarnings("unchecked")
4250
private static final ConstructingObjectParser<RoleMappingMetadata, Void> PARSER = new ConstructingObjectParser<>(
@@ -46,12 +54,7 @@ public final class RoleMappingMetadata extends AbstractNamedDiffable<Metadata.Cu
4654
);
4755

4856
static {
49-
PARSER.declareObjectArray(
50-
constructorArg(),
51-
// role mapping names are lost when the role mapping metadata is serialized
52-
(p, c) -> ExpressionRoleMapping.parse("name_not_available_after_deserialization", p),
53-
new ParseField(TYPE)
54-
);
57+
PARSER.declareObjectArray(constructorArg(), (p, c) -> parseWithNameFromMetadata(p), new ParseField(TYPE));
5558
}
5659

5760
private static final RoleMappingMetadata EMPTY = new RoleMappingMetadata(Set.of());
@@ -153,4 +156,64 @@ public EnumSet<Metadata.XContentContext> context() {
153156
// are not persisted.
154157
return ALL_CONTEXTS;
155158
}
159+
160+
/**
161+
* Ensures role mapping names are preserved when stored on disk using XContent format,
162+
* which omits names. This method copies the role mapping's name into a reserved metadata field
163+
* during serialization, allowing recovery during deserialization (e.g., after a master-node restart).
164+
* {@link #parseWithNameFromMetadata(XContentParser)} restores the name during parsing.
165+
*/
166+
public static ExpressionRoleMapping copyWithNameInMetadata(ExpressionRoleMapping roleMapping) {
167+
Map<String, Object> metadata = new HashMap<>(roleMapping.getMetadata());
168+
// note: can't use Maps.copyWith... since these create maps that don't support `null` values in map entries
169+
if (metadata.put(METADATA_NAME_FIELD, roleMapping.getName()) != null) {
170+
logger.error(
171+
"Metadata field [{}] is reserved and will be overwritten with an internal system value. "
172+
+ "Rename this field in your role mapping configuration.",
173+
METADATA_NAME_FIELD
174+
);
175+
}
176+
return new ExpressionRoleMapping(
177+
roleMapping.getName(),
178+
roleMapping.getExpression(),
179+
roleMapping.getRoles(),
180+
roleMapping.getRoleTemplates(),
181+
metadata,
182+
roleMapping.isEnabled()
183+
);
184+
}
185+
186+
/**
187+
* If a role mapping does not yet have a name persisted in metadata, it will use a constant fallback name. This method checks if a
188+
* role mapping has the fallback name.
189+
*/
190+
public static boolean hasFallbackName(ExpressionRoleMapping expressionRoleMapping) {
191+
return expressionRoleMapping.getName().equals(FALLBACK_NAME);
192+
}
193+
194+
/**
195+
* Parse a role mapping from XContent, restoring the name from a reserved metadata field.
196+
* Used to parse a role mapping annotated with its name in metadata via @see {@link #copyWithNameInMetadata(ExpressionRoleMapping)}.
197+
*/
198+
public static ExpressionRoleMapping parseWithNameFromMetadata(XContentParser parser) throws IOException {
199+
ExpressionRoleMapping roleMapping = ExpressionRoleMapping.parse(FALLBACK_NAME, parser);
200+
return new ExpressionRoleMapping(
201+
getNameFromMetadata(roleMapping),
202+
roleMapping.getExpression(),
203+
roleMapping.getRoles(),
204+
roleMapping.getRoleTemplates(),
205+
roleMapping.getMetadata(),
206+
roleMapping.isEnabled()
207+
);
208+
}
209+
210+
private static String getNameFromMetadata(ExpressionRoleMapping roleMapping) {
211+
Map<String, Object> metadata = roleMapping.getMetadata();
212+
if (metadata.containsKey(METADATA_NAME_FIELD) && metadata.get(METADATA_NAME_FIELD) instanceof String name) {
213+
return name;
214+
} else {
215+
// This is valid the first time we recover from cluster-state: the old format metadata won't have a name stored in metadata yet
216+
return FALLBACK_NAME;
217+
}
218+
}
156219
}

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

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import org.elasticsearch.core.Tuple;
2020
import org.elasticsearch.test.TestSecurityClient;
2121
import org.elasticsearch.test.cluster.ElasticsearchCluster;
22+
import org.elasticsearch.test.cluster.local.LocalClusterConfigProvider;
2223
import org.elasticsearch.test.cluster.local.distribution.DistributionType;
2324
import org.elasticsearch.test.cluster.util.resource.Resource;
2425
import org.elasticsearch.test.rest.ESRestTestCase;
@@ -41,9 +42,7 @@
4142
public abstract class SecurityOnTrialLicenseRestTestCase extends ESRestTestCase {
4243
private TestSecurityClient securityClient;
4344

44-
@ClassRule
45-
public static ElasticsearchCluster cluster = ElasticsearchCluster.local()
46-
.nodes(2)
45+
public static LocalClusterConfigProvider commonTrialSecurityClusterConfig = cluster -> cluster.nodes(2)
4746
.distribution(DistributionType.DEFAULT)
4847
.setting("xpack.ml.enabled", "false")
4948
.setting("xpack.license.self_generated.type", "trial")
@@ -62,8 +61,10 @@ public abstract class SecurityOnTrialLicenseRestTestCase extends ESRestTestCase
6261
.user("admin_user", "admin-password", ROOT_USER_ROLE, true)
6362
.user("security_test_user", "security-test-password", "security_test_role", false)
6463
.user("x_pack_rest_user", "x-pack-test-password", ROOT_USER_ROLE, true)
65-
.user("cat_test_user", "cat-test-password", "cat_test_role", false)
66-
.build();
64+
.user("cat_test_user", "cat-test-password", "cat_test_role", false);
65+
66+
@ClassRule
67+
public static ElasticsearchCluster cluster = ElasticsearchCluster.local().apply(commonTrialSecurityClusterConfig).build();
6768

6869
@Override
6970
protected String getTestRestCluster() {

0 commit comments

Comments
 (0)