Skip to content

Commit fb32adc

Browse files
authored
Add manage roles privilege (#110633)
This PR adds functionality to limit the resources and privileges an Elasticsearch user can grant permissions to when creating a role. This is achieved using a new [global](https://www.elastic.co/guide/en/elasticsearch/reference/current/defining-roles.html) (configurable/request aware) cluster privilege , named `role`, with a sub-key called `manage/indices` which is an array where each entry is a pair of [index patterns](https://docs.google.com/document/d/1VN73C2KpmvvOW85-XGUqMmnMwXrfK4aoxRtG8tPqk7Y/edit#heading=h.z74zwo30t0pf) and [index privileges](https://www.elastic.co/guide/en/elasticsearch/reference/current/security-privileges.html#privileges-list-indices). ## Definition - Using a role with this privilege to create, update or delete roles with privileges on indices outside of the indices matched by the [index pattern](https://docs.google.com/document/d/1VN73C2KpmvvOW85-XGUqMmnMwXrfK4aoxRtG8tPqk7Y/edit#heading=h.z74zwo30t0pf) in the indices array, will fail. - Using a role with this privilege to try to create, update or delete roles with cluster, run_as, etc. privileges will fail. - Using a role with this privilege with restricted indices will fail. - Other broader privileges (such as manage_security) will nullify this privilege. ## Example Create `test-manage` role: ``` POST _security/role/test-manage { "global": { "role": { "manage": { "indices": [ { "names": ["allowed-index-prefix-*"], "privileges":["read"] } ] } } } } ``` And then a user with that role creates a role: ``` POST _security/role/a-test-role { "indices": [ { "names": [ "allowed-index-prefix-some-index" ], "privileges": [ "read" ]}] } ``` But this would fail for: ``` POST _security/role/a-test-role { "indices": [ { "names": [ "not-allowed-index-prefix-some-index" ], "privileges": [ "read" ]}] } ``` ## Backwards compatibility and mixed cluster concerns - A new mapping version has been added to the security index to store the new privilege. - If the new mapping version is not applied and a role descriptor with the new global privilege is written, the write will fail causing an exception. - When sending role descriptors over the transport layer in a mixed cluster, the new global privilege needs to be excluded for older versions. This is hanled with a new transport version. - If a role descriptor is serialized for API keys on one node in a mixed cluster and read from another, an older node might not be able to deserialize it, so it needs to be removed before being written in mixed cluster with old nodes. This is handled in the API key service. - If a role descriptor containing a global privilege is in a put role request in a mixed cluster where it's not supported on all nodes, fail request to create role. - RCS is not applicable here since RCS only considers cluster privileges and index privileges (not global cluster privileges). - This doesn't include remote privileges, since the current use case with connectors doesn't need roles to be created on a cluster separate from the cluster where the search data resides. ## Follow up work - Create a docs PR - Error handling for actions that use manage roles. Should configurable cluster privileges that grant restricted usage of actions be listed in error authorization error messages?
1 parent 73c5c1e commit fb32adc

File tree

20 files changed

+1397
-102
lines changed

20 files changed

+1397
-102
lines changed

docs/changelog/110633.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
pr: 110633
2+
summary: Add manage roles privilege
3+
area: Authorization
4+
type: enhancement
5+
issues: []

server/src/main/java/org/elasticsearch/TransportVersions.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,7 @@ static TransportVersion def(int id) {
198198
public static final TransportVersion ALLOW_PARTIAL_SEARCH_RESULTS_IN_PIT = def(8_728_00_0);
199199
public static final TransportVersion RANK_DOCS_RETRIEVER = def(8_729_00_0);
200200
public static final TransportVersion ESQL_ES_FIELD_CACHED_SERIALIZATION = def(8_730_00_0);
201+
public static final TransportVersion ADD_MANAGE_ROLES_PRIVILEGE = def(8_731_00_0);
201202
/*
202203
* STOP! READ THIS FIRST! No, really,
203204
* ____ _____ ___ ____ _ ____ _____ _ ____ _____ _ _ ___ ____ _____ ___ ____ ____ _____ _

x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/XPackClientPlugin.java

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@ public List<NamedWriteableRegistry.Entry> getNamedWriteables() {
149149
new NamedWriteableRegistry.Entry(ClusterState.Custom.class, TokenMetadata.TYPE, TokenMetadata::new),
150150
new NamedWriteableRegistry.Entry(NamedDiff.class, TokenMetadata.TYPE, TokenMetadata::readDiffFrom),
151151
new NamedWriteableRegistry.Entry(XPackFeatureSet.Usage.class, XPackField.SECURITY, SecurityFeatureSetUsage::new),
152-
// security : conditional privileges
152+
// security : configurable cluster privileges
153153
new NamedWriteableRegistry.Entry(
154154
ConfigurableClusterPrivilege.class,
155155
ConfigurableClusterPrivileges.ManageApplicationPrivileges.WRITEABLE_NAME,
@@ -160,6 +160,11 @@ public List<NamedWriteableRegistry.Entry> getNamedWriteables() {
160160
ConfigurableClusterPrivileges.WriteProfileDataPrivileges.WRITEABLE_NAME,
161161
ConfigurableClusterPrivileges.WriteProfileDataPrivileges::createFrom
162162
),
163+
new NamedWriteableRegistry.Entry(
164+
ConfigurableClusterPrivilege.class,
165+
ConfigurableClusterPrivileges.ManageRolesPrivilege.WRITEABLE_NAME,
166+
ConfigurableClusterPrivileges.ManageRolesPrivilege::createFrom
167+
),
163168
// security : role-mappings
164169
new NamedWriteableRegistry.Entry(Metadata.Custom.class, RoleMappingMetadata.TYPE, RoleMappingMetadata::new),
165170
new NamedWriteableRegistry.Entry(NamedDiff.class, RoleMappingMetadata.TYPE, RoleMappingMetadata::readDiffFrom),

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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,15 @@
1010
import org.apache.lucene.util.automaton.Operations;
1111
import org.elasticsearch.transport.TransportRequest;
1212
import org.elasticsearch.xpack.core.security.authc.Authentication;
13+
import org.elasticsearch.xpack.core.security.authz.RestrictedIndices;
1314
import org.elasticsearch.xpack.core.security.authz.privilege.ClusterPrivilege;
1415
import org.elasticsearch.xpack.core.security.support.Automatons;
1516

1617
import java.util.ArrayList;
1718
import java.util.HashSet;
1819
import java.util.List;
1920
import java.util.Set;
21+
import java.util.function.Function;
2022
import java.util.function.Predicate;
2123

2224
/**
@@ -84,6 +86,16 @@ public static class Builder {
8486
private final List<Automaton> actionAutomatons = new ArrayList<>();
8587
private final List<PermissionCheck> permissionChecks = new ArrayList<>();
8688

89+
private final RestrictedIndices restrictedIndices;
90+
91+
public Builder(RestrictedIndices restrictedIndices) {
92+
this.restrictedIndices = restrictedIndices;
93+
}
94+
95+
public Builder() {
96+
this.restrictedIndices = null;
97+
}
98+
8799
public Builder add(
88100
final ClusterPrivilege clusterPrivilege,
89101
final Set<String> allowedActionPatterns,
@@ -110,6 +122,16 @@ public Builder add(final ClusterPrivilege clusterPrivilege, final PermissionChec
110122
return this;
111123
}
112124

125+
public Builder addWithPredicateSupplier(
126+
final ClusterPrivilege clusterPrivilege,
127+
final Set<String> allowedActionPatterns,
128+
final Function<RestrictedIndices, Predicate<TransportRequest>> requestPredicateSupplier
129+
) {
130+
final Automaton actionAutomaton = createAutomaton(allowedActionPatterns, Set.of());
131+
Predicate<TransportRequest> requestPredicate = requestPredicateSupplier.apply(restrictedIndices);
132+
return add(clusterPrivilege, new ActionRequestBasedPermissionCheck(clusterPrivilege, actionAutomaton, requestPredicate));
133+
}
134+
113135
public ClusterPermission build() {
114136
if (clusterPrivileges.isEmpty()) {
115137
return NONE;

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

Lines changed: 81 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import org.elasticsearch.common.util.Maps;
2121
import org.elasticsearch.common.util.set.Sets;
2222
import org.elasticsearch.core.Nullable;
23+
import org.elasticsearch.core.Tuple;
2324
import org.elasticsearch.index.Index;
2425
import org.elasticsearch.xpack.core.security.authz.RestrictedIndices;
2526
import org.elasticsearch.xpack.core.security.authz.accesscontrol.IndicesAccessControl;
@@ -86,6 +87,7 @@ public Builder addGroup(
8687
public IndicesPermission build() {
8788
return new IndicesPermission(restrictedIndices, groups.toArray(Group.EMPTY_ARRAY));
8889
}
90+
8991
}
9092

9193
private IndicesPermission(RestrictedIndices restrictedIndices, Group[] groups) {
@@ -238,6 +240,21 @@ public boolean check(String action) {
238240
return false;
239241
}
240242

243+
public boolean checkResourcePrivileges(
244+
Set<String> checkForIndexPatterns,
245+
boolean allowRestrictedIndices,
246+
Set<String> checkForPrivileges,
247+
@Nullable ResourcePrivilegesMap.Builder resourcePrivilegesMapBuilder
248+
) {
249+
return checkResourcePrivileges(
250+
checkForIndexPatterns,
251+
allowRestrictedIndices,
252+
checkForPrivileges,
253+
false,
254+
resourcePrivilegesMapBuilder
255+
);
256+
}
257+
241258
/**
242259
* For given index patterns and index privileges determines allowed privileges and creates an instance of {@link ResourcePrivilegesMap}
243260
* holding a map of resource to {@link ResourcePrivileges} where resource is index pattern and the map of index privilege to whether it
@@ -246,6 +263,7 @@ public boolean check(String action) {
246263
* @param checkForIndexPatterns check permission grants for the set of index patterns
247264
* @param allowRestrictedIndices if {@code true} then checks permission grants even for restricted indices by index matching
248265
* @param checkForPrivileges check permission grants for the set of index privileges
266+
* @param combineIndexGroups combine index groups to enable checking against regular expressions
249267
* @param resourcePrivilegesMapBuilder out-parameter for returning the details on which privilege over which resource is granted or not.
250268
* Can be {@code null} when no such details are needed so the method can return early, after
251269
* encountering the first privilege that is not granted over some resource.
@@ -255,26 +273,28 @@ public boolean checkResourcePrivileges(
255273
Set<String> checkForIndexPatterns,
256274
boolean allowRestrictedIndices,
257275
Set<String> checkForPrivileges,
276+
boolean combineIndexGroups,
258277
@Nullable ResourcePrivilegesMap.Builder resourcePrivilegesMapBuilder
259278
) {
260-
final Map<IndicesPermission.Group, Automaton> predicateCache = new HashMap<>();
261279
boolean allMatch = true;
280+
Map<Automaton, Automaton> indexGroupAutomatons = indexGroupAutomatons(
281+
combineIndexGroups && checkForIndexPatterns.stream().anyMatch(Automatons::isLuceneRegex)
282+
);
262283
for (String forIndexPattern : checkForIndexPatterns) {
263284
Automaton checkIndexAutomaton = Automatons.patterns(forIndexPattern);
264285
if (false == allowRestrictedIndices && false == isConcreteRestrictedIndex(forIndexPattern)) {
265286
checkIndexAutomaton = Automatons.minusAndMinimize(checkIndexAutomaton, restrictedIndices.getAutomaton());
266287
}
267288
if (false == Operations.isEmpty(checkIndexAutomaton)) {
268289
Automaton allowedIndexPrivilegesAutomaton = null;
269-
for (Group group : groups) {
270-
final Automaton groupIndexAutomaton = predicateCache.computeIfAbsent(group, Group::getIndexMatcherAutomaton);
271-
if (Operations.subsetOf(checkIndexAutomaton, groupIndexAutomaton)) {
290+
for (var indexAndPrivilegeAutomaton : indexGroupAutomatons.entrySet()) {
291+
if (Operations.subsetOf(checkIndexAutomaton, indexAndPrivilegeAutomaton.getValue())) {
272292
if (allowedIndexPrivilegesAutomaton != null) {
273293
allowedIndexPrivilegesAutomaton = Automatons.unionAndMinimize(
274-
Arrays.asList(allowedIndexPrivilegesAutomaton, group.privilege().getAutomaton())
294+
Arrays.asList(allowedIndexPrivilegesAutomaton, indexAndPrivilegeAutomaton.getKey())
275295
);
276296
} else {
277-
allowedIndexPrivilegesAutomaton = group.privilege().getAutomaton();
297+
allowedIndexPrivilegesAutomaton = indexAndPrivilegeAutomaton.getKey();
278298
}
279299
}
280300
}
@@ -656,6 +676,61 @@ private static boolean containsPrivilegeThatGrantsMappingUpdatesForBwc(Group gro
656676
return group.privilege().name().stream().anyMatch(PRIVILEGE_NAME_SET_BWC_ALLOW_MAPPING_UPDATE::contains);
657677
}
658678

679+
/**
680+
* Get all automatons for the index groups in this permission and optionally combine the index groups to enable checking if a set of
681+
* index patterns specified using a regular expression grants a set of index privileges.
682+
*
683+
* <p>An index group is defined as a set of index patterns and a set of privileges (excluding field permissions and DLS queries).
684+
* {@link IndicesPermission} consist of a set of index groups. For non-regular expression privilege checks, an index pattern is checked
685+
* against each index group, to see if it's a sub-pattern of the index pattern for the group and then if that group grants some or all
686+
* of the privileges requested. For regular expressions it's not sufficient to check per group since the index patterns covered by a
687+
* group can be distinct sets and a regular expression can cover several distinct sets.
688+
*
689+
* <p>For example the two index groups: {"names": ["a"], "privileges": ["read", "create"]} and {"names": ["b"],
690+
* "privileges": ["read","delete"]} will not match on ["\[ab]\"], while a single index group:
691+
* {"names": ["a", "b"], "privileges": ["read"]} will. This happens because the index groups are evaluated against a request index
692+
* pattern without first being combined. In the example above, the two index patterns should be combined to:
693+
* {"names": ["a", "b"], "privileges": ["read"]} before being checked.
694+
*
695+
*
696+
* @param combine combine index groups to allow for checking against regular expressions
697+
*
698+
* @return a map of all index and privilege pattern automatons
699+
*/
700+
private Map<Automaton, Automaton> indexGroupAutomatons(boolean combine) {
701+
// Map of privilege automaton object references (cached by IndexPrivilege::CACHE)
702+
Map<Automaton, Automaton> allAutomatons = new HashMap<>();
703+
for (Group group : groups) {
704+
Automaton indexAutomaton = group.getIndexMatcherAutomaton();
705+
allAutomatons.compute(
706+
group.privilege().getAutomaton(),
707+
(key, value) -> value == null ? indexAutomaton : Automatons.unionAndMinimize(List.of(value, indexAutomaton))
708+
);
709+
if (combine) {
710+
List<Tuple<Automaton, Automaton>> combinedAutomatons = new ArrayList<>();
711+
for (var indexAndPrivilegeAutomatons : allAutomatons.entrySet()) {
712+
Automaton intersectingPrivileges = Operations.intersection(
713+
indexAndPrivilegeAutomatons.getKey(),
714+
group.privilege().getAutomaton()
715+
);
716+
if (Operations.isEmpty(intersectingPrivileges) == false) {
717+
Automaton indexPatternAutomaton = Automatons.unionAndMinimize(
718+
List.of(indexAndPrivilegeAutomatons.getValue(), indexAutomaton)
719+
);
720+
combinedAutomatons.add(new Tuple<>(intersectingPrivileges, indexPatternAutomaton));
721+
}
722+
}
723+
combinedAutomatons.forEach(
724+
automatons -> allAutomatons.compute(
725+
automatons.v1(),
726+
(key, value) -> value == null ? automatons.v2() : Automatons.unionAndMinimize(List.of(value, automatons.v2()))
727+
)
728+
);
729+
}
730+
}
731+
return allAutomatons;
732+
}
733+
659734
public static class Group {
660735
public static final Group[] EMPTY_ARRAY = new Group[0];
661736

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -233,7 +233,7 @@ private Builder(RestrictedIndices restrictedIndices, String[] names) {
233233
}
234234

235235
public Builder cluster(Set<String> privilegeNames, Iterable<ConfigurableClusterPrivilege> configurableClusterPrivileges) {
236-
ClusterPermission.Builder builder = ClusterPermission.builder();
236+
ClusterPermission.Builder builder = new ClusterPermission.Builder(restrictedIndices);
237237
if (privilegeNames.isEmpty() == false) {
238238
for (String name : privilegeNames) {
239239
builder = ClusterPrivilegeResolver.resolve(name).buildPermission(builder);

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,8 @@ public interface ConfigurableClusterPrivilege extends NamedWriteable, ToXContent
4141
*/
4242
enum Category {
4343
APPLICATION(new ParseField("application")),
44-
PROFILE(new ParseField("profile"));
44+
PROFILE(new ParseField("profile")),
45+
ROLE(new ParseField("role"));
4546

4647
public final ParseField field;
4748

0 commit comments

Comments
 (0)