Skip to content

Commit 6ac111b

Browse files
Role changes to support enforcing workflow restrictions (#96744)
This PR implements necessary changes to `Role` classes in order to enforce workflow restrictions for API keys. Main change is around resolving roles from role descriptors, where a role can either be effectively empty (or not) depending on its workflow restriction. Currently, the only workflow supported is `search_application_query` which allows restricting access only to Search Application Search API. For simplicity, when creating an API key, it's possible to specify only a single role descriptor with workflows restriction. For example: ``` POST /_security/api_key { "name": "my_restricted_api_key", "role_descriptors": { "my_restricted_role": { "indices": [ { "names": ["books"], "privileges": ["read"] } ], "restriction": { "workflows": ["search_application_query"] } } } } ``` Relates to #96215
1 parent 248498d commit 6ac111b

File tree

25 files changed

+985
-34
lines changed

25 files changed

+985
-34
lines changed

docs/changelog/96744.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
pr: 96744
2+
summary: Support restricting access of API keys to only certain workflows
3+
area: Authorization
4+
type: feature
5+
issues: []

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1838,6 +1838,12 @@ private enum ElasticsearchExceptionHandle {
18381838
org.elasticsearch.http.HttpHeadersValidationException::new,
18391839
169,
18401840
TransportVersion.V_8_9_0
1841+
),
1842+
ROLE_RESTRICTION_EXCEPTION(
1843+
ElasticsearchRoleRestrictionException.class,
1844+
ElasticsearchRoleRestrictionException::new,
1845+
170,
1846+
TransportVersion.V_8_500_016
18411847
);
18421848

18431849
final Class<? extends ElasticsearchException> exceptionClass;
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
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 and the Server Side Public License, v 1; you may not use this file except
5+
* in compliance with, at your election, the Elastic License 2.0 or the Server
6+
* Side Public License, v 1.
7+
*/
8+
9+
package org.elasticsearch;
10+
11+
import org.elasticsearch.common.io.stream.StreamInput;
12+
import org.elasticsearch.rest.RestStatus;
13+
14+
import java.io.IOException;
15+
16+
/**
17+
* This exception is thrown to indicate that the access has been denied because of role restrictions that
18+
* an authenticated subject might have (e.g. not allowed to access certain APIs).
19+
* This differs from other 403 error in sense that it's additional access control that is enforced after role
20+
* is resolved and before permissions are checked.
21+
*/
22+
public class ElasticsearchRoleRestrictionException extends ElasticsearchSecurityException {
23+
24+
public ElasticsearchRoleRestrictionException(String msg, Throwable cause, Object... args) {
25+
super(msg, RestStatus.FORBIDDEN, cause, args);
26+
}
27+
28+
public ElasticsearchRoleRestrictionException(String msg, Object... args) {
29+
this(msg, null, args);
30+
}
31+
32+
public ElasticsearchRoleRestrictionException(StreamInput in) throws IOException {
33+
super(in);
34+
}
35+
}

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,9 +140,10 @@ private static TransportVersion registerTransportVersion(int id, String uniqueId
140140
public static final TransportVersion V_8_500_013 = registerTransportVersion(8_500_013, "f65b85ac-db5e-4558-a487-a1dde4f6a33a");
141141
public static final TransportVersion V_8_500_014 = registerTransportVersion(8_500_014, "D115A2E1-1739-4A02-AB7B-64F6EA157EFB");
142142
public static final TransportVersion V_8_500_015 = registerTransportVersion(8_500_015, "651216c9-d54f-4189-9fe1-48d82d276863");
143+
public static final TransportVersion V_8_500_016 = registerTransportVersion(8_500_016, "492C94FB-AAEA-4C9E-8375-BDB67A398584");
143144

144145
private static class CurrentHolder {
145-
private static final TransportVersion CURRENT = findCurrent(V_8_500_015);
146+
private static final TransportVersion CURRENT = findCurrent(V_8_500_016);
146147

147148
// finds the pluggable current version, or uses the given fallback
148149
private static TransportVersion findCurrent(TransportVersion fallback) {

server/src/test/java/org/elasticsearch/ExceptionSerializationTests.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -829,6 +829,7 @@ public void testIds() {
829829
ids.put(167, UnsupportedAggregationOnDownsampledIndex.class);
830830
ids.put(168, DocumentParsingException.class);
831831
ids.put(169, HttpHeadersValidationException.class);
832+
ids.put(170, ElasticsearchRoleRestrictionException.class);
832833

833834
Map<Class<? extends ElasticsearchException>, Integer> reverse = new HashMap<>();
834835
for (Map.Entry<Integer, Class<? extends ElasticsearchException>> entry : ids.entrySet()) {

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

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ public final class LimitedRole implements Role {
5151
public LimitedRole(Role baseRole, Role limitedByRole) {
5252
this.baseRole = Objects.requireNonNull(baseRole);
5353
this.limitedByRole = Objects.requireNonNull(limitedByRole, "limited by role is required to create limited role");
54+
assert false == limitedByRole.hasWorkflowsRestriction() : "limited-by role must not have workflows restriction";
5455
}
5556

5657
@Override
@@ -74,6 +75,28 @@ public RemoteIndicesPermission remoteIndices() {
7475
throw new UnsupportedOperationException("cannot retrieve remote indices permission on limited role");
7576
}
7677

78+
@Override
79+
public boolean hasWorkflowsRestriction() {
80+
return baseRole.hasWorkflowsRestriction() || limitedByRole.hasWorkflowsRestriction();
81+
}
82+
83+
@Override
84+
public Role forWorkflow(String workflow) {
85+
Role baseRestricted = baseRole.forWorkflow(workflow);
86+
if (baseRestricted == EMPTY_RESTRICTED_BY_WORKFLOW) {
87+
return EMPTY_RESTRICTED_BY_WORKFLOW;
88+
}
89+
Role limitedByRestricted = limitedByRole.forWorkflow(workflow);
90+
if (limitedByRestricted == EMPTY_RESTRICTED_BY_WORKFLOW) {
91+
return EMPTY_RESTRICTED_BY_WORKFLOW;
92+
}
93+
if (baseRestricted == baseRole && limitedByRestricted == limitedByRole) {
94+
return this;
95+
} else {
96+
return baseRestricted.limitedBy(limitedByRestricted);
97+
}
98+
}
99+
77100
@Override
78101
public ApplicationPermission application() {
79102
throw new UnsupportedOperationException("cannot retrieve application permission on limited role");

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

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@
2727
import org.elasticsearch.xpack.core.security.authz.privilege.ConfigurableClusterPrivilege;
2828
import org.elasticsearch.xpack.core.security.authz.privilege.IndexPrivilege;
2929
import org.elasticsearch.xpack.core.security.authz.privilege.Privilege;
30+
import org.elasticsearch.xpack.core.security.authz.restriction.WorkflowResolver;
31+
import org.elasticsearch.xpack.core.security.authz.restriction.WorkflowsRestriction;
3032
import org.elasticsearch.xpack.core.security.support.Automatons;
3133

3234
import java.util.ArrayList;
@@ -46,6 +48,8 @@ public interface Role {
4648

4749
Role EMPTY = builder(new RestrictedIndices(Automatons.EMPTY)).build();
4850

51+
Role EMPTY_RESTRICTED_BY_WORKFLOW = builder(new RestrictedIndices(Automatons.EMPTY)).workflows(Set.of()).build();
52+
4953
String[] names();
5054

5155
ClusterPermission cluster();
@@ -58,6 +62,19 @@ public interface Role {
5862

5963
RemoteIndicesPermission remoteIndices();
6064

65+
boolean hasWorkflowsRestriction();
66+
67+
/**
68+
* This method returns an effective role for the given workflow if role has workflows restriction
69+
* (i.e. {@link #hasWorkflowsRestriction} is true). Otherwise, this method returns an unchanged role.
70+
*
71+
* The returned effective role can be an {@link #EMPTY_RESTRICTED_BY_WORKFLOW} when the given workflow is
72+
* not one of the workflows to which this role is restricted.
73+
*
74+
* The workflows to which a role can be restricted are static and defined in {@link WorkflowResolver}.
75+
*/
76+
Role forWorkflow(@Nullable String workflow);
77+
6178
/**
6279
* Whether the Role has any field or document level security enabled index privileges
6380
* @return
@@ -176,7 +193,7 @@ IndicesAccessControl authorize(
176193
/***
177194
* Creates a {@link LimitedRole} that uses this Role as base and the given role as limited-by.
178195
*/
179-
default LimitedRole limitedBy(Role role) {
196+
default Role limitedBy(Role role) {
180197
return new LimitedRole(this, role);
181198
}
182199

@@ -200,6 +217,7 @@ class Builder {
200217
private final Map<Set<String>, List<IndicesPermissionGroupDefinition>> remoteGroups = new HashMap<>();
201218
private final List<Tuple<ApplicationPrivilege, Set<String>>> applicationPrivs = new ArrayList<>();
202219
private final RestrictedIndices restrictedIndices;
220+
private WorkflowsRestriction workflowsRestriction = WorkflowsRestriction.NONE;
203221

204222
private Builder(RestrictedIndices restrictedIndices, String[] names) {
205223
this.restrictedIndices = restrictedIndices;
@@ -259,6 +277,15 @@ public Builder addApplicationPrivilege(ApplicationPrivilege privilege, Set<Strin
259277
return this;
260278
}
261279

280+
public Builder workflows(Set<String> workflowNames) {
281+
if (workflowNames == null) {
282+
this.workflowsRestriction = WorkflowsRestriction.NONE;
283+
} else {
284+
this.workflowsRestriction = new WorkflowsRestriction(workflowNames);
285+
}
286+
return this;
287+
}
288+
262289
public SimpleRole build() {
263290
final IndicesPermission indices;
264291
if (groups.isEmpty()) {
@@ -301,7 +328,7 @@ public SimpleRole build() {
301328
final ApplicationPermission applicationPermission = applicationPrivs.isEmpty()
302329
? ApplicationPermission.NONE
303330
: new ApplicationPermission(applicationPrivs);
304-
return new SimpleRole(names, cluster, indices, applicationPermission, runAs, remoteIndices);
331+
return new SimpleRole(names, cluster, indices, applicationPermission, runAs, remoteIndices, workflowsRestriction);
305332
}
306333

307334
private static class IndicesPermissionGroupDefinition {
@@ -392,6 +419,10 @@ static SimpleRole buildFromRoleDescriptor(
392419
builder.runAs(new Privilege(Sets.newHashSet(rdRunAs), rdRunAs));
393420
}
394421

422+
if (roleDescriptor.hasWorkflowsRestriction()) {
423+
builder.workflows(Sets.newHashSet(roleDescriptor.getRestriction().getWorkflows()));
424+
}
425+
395426
return builder.build();
396427
}
397428
}

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

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
import org.elasticsearch.xpack.core.security.authz.permission.IndicesPermission.IsResourceAuthorizedPredicate;
2525
import org.elasticsearch.xpack.core.security.authz.privilege.ApplicationPrivilegeDescriptor;
2626
import org.elasticsearch.xpack.core.security.authz.privilege.ClusterPrivilege;
27+
import org.elasticsearch.xpack.core.security.authz.restriction.WorkflowsRestriction;
2728

2829
import java.util.ArrayList;
2930
import java.util.Arrays;
@@ -51,21 +52,24 @@ public class SimpleRole implements Role {
5152
private final ApplicationPermission application;
5253
private final RunAsPermission runAs;
5354
private final RemoteIndicesPermission remoteIndices;
55+
private final WorkflowsRestriction workflowsRestriction;
5456

5557
SimpleRole(
5658
String[] names,
5759
ClusterPermission cluster,
5860
IndicesPermission indices,
5961
ApplicationPermission application,
6062
RunAsPermission runAs,
61-
RemoteIndicesPermission remoteIndices
63+
RemoteIndicesPermission remoteIndices,
64+
WorkflowsRestriction workflowsRestriction
6265
) {
6366
this.names = names;
6467
this.cluster = Objects.requireNonNull(cluster);
6568
this.indices = Objects.requireNonNull(indices);
6669
this.application = Objects.requireNonNull(application);
6770
this.runAs = Objects.requireNonNull(runAs);
6871
this.remoteIndices = Objects.requireNonNull(remoteIndices);
72+
this.workflowsRestriction = Objects.requireNonNull(workflowsRestriction);
6973
}
7074

7175
@Override
@@ -98,6 +102,20 @@ public RemoteIndicesPermission remoteIndices() {
98102
return remoteIndices;
99103
}
100104

105+
@Override
106+
public boolean hasWorkflowsRestriction() {
107+
return workflowsRestriction.hasWorkflows();
108+
}
109+
110+
@Override
111+
public Role forWorkflow(String workflow) {
112+
if (workflowsRestriction.isWorkflowAllowed(workflow)) {
113+
return this;
114+
} else {
115+
return EMPTY_RESTRICTED_BY_WORKFLOW;
116+
}
117+
}
118+
101119
@Override
102120
public boolean hasFieldOrDocumentLevelSecurity() {
103121
return indices.hasFieldOrDocumentLevelSecurity();
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
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.authz.restriction;
9+
10+
import org.elasticsearch.core.Nullable;
11+
12+
import java.util.Set;
13+
import java.util.function.Predicate;
14+
15+
public final class WorkflowsRestriction {
16+
17+
/**
18+
* Default behaviour is no restriction which allows all workflows.
19+
*/
20+
public static final WorkflowsRestriction NONE = new WorkflowsRestriction(null);
21+
22+
private final Set<String> names;
23+
private final Predicate<String> predicate;
24+
25+
public WorkflowsRestriction(Set<String> names) {
26+
this.names = names;
27+
if (names == null) {
28+
// No restriction, all workflows are allowed
29+
this.predicate = name -> true;
30+
} else if (names.isEmpty()) {
31+
// Empty restriction, no workflow is allowed
32+
this.predicate = name -> false;
33+
} else {
34+
this.predicate = name -> {
35+
if (name == null) {
36+
return false;
37+
} else {
38+
return names.contains(name);
39+
}
40+
};
41+
}
42+
}
43+
44+
public boolean hasWorkflows() {
45+
return this.names != null;
46+
}
47+
48+
public boolean isWorkflowAllowed(@Nullable String workflow) {
49+
return predicate.test(workflow);
50+
}
51+
52+
}

x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/authz/permission/LimitedRoleTests.java

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@
3838
import org.elasticsearch.xpack.core.security.authz.privilege.ApplicationPrivilegeTests;
3939
import org.elasticsearch.xpack.core.security.authz.privilege.ClusterPrivilegeResolver;
4040
import org.elasticsearch.xpack.core.security.authz.privilege.IndexPrivilege;
41+
import org.elasticsearch.xpack.core.security.authz.restriction.Workflow;
42+
import org.elasticsearch.xpack.core.security.authz.restriction.WorkflowResolver;
4143
import org.elasticsearch.xpack.core.security.support.Automatons;
4244
import org.junit.Before;
4345

@@ -54,6 +56,7 @@
5456
import static org.hamcrest.Matchers.is;
5557
import static org.hamcrest.Matchers.notNullValue;
5658
import static org.hamcrest.Matchers.nullValue;
59+
import static org.hamcrest.Matchers.sameInstance;
5760
import static org.mockito.Mockito.mock;
5861
import static org.mockito.Mockito.when;
5962

@@ -767,6 +770,41 @@ public void testHasPrivilegesForIndexPatterns() {
767770
}
768771
}
769772

773+
public void testForWorkflowRestriction() {
774+
// Test when role is restricted to the same workflow as originating workflow
775+
{
776+
Workflow workflow = WorkflowResolver.SEARCH_APPLICATION_QUERY_WORKFLOW;
777+
Role baseRole = Role.builder(EMPTY_RESTRICTED_INDICES, "role-a")
778+
.add(IndexPrivilege.READ, "index-a")
779+
.workflows(Set.of(workflow.name()))
780+
.build();
781+
Role limitedBy = Role.builder(EMPTY_RESTRICTED_INDICES, "role-b").add(IndexPrivilege.READ, "index-a").build();
782+
Role role = baseRole.limitedBy(limitedBy);
783+
assertThat(role.hasWorkflowsRestriction(), equalTo(true));
784+
assertThat(role.forWorkflow(workflow.name()), sameInstance(role));
785+
}
786+
// Test restriction when role is not restricted regardless of originating workflow
787+
{
788+
String originatingWorkflow = randomBoolean() ? null : WorkflowResolver.SEARCH_APPLICATION_QUERY_WORKFLOW.name();
789+
Role baseRole = Role.builder(EMPTY_RESTRICTED_INDICES, "role-a").add(IndexPrivilege.READ, "index-a").build();
790+
Role limitedBy = Role.builder(EMPTY_RESTRICTED_INDICES, "role-b").add(IndexPrivilege.READ, "index-a").build();
791+
Role role = baseRole.limitedBy(limitedBy);
792+
assertThat(role.forWorkflow(originatingWorkflow), sameInstance(role));
793+
assertThat(role.hasWorkflowsRestriction(), equalTo(false));
794+
}
795+
// Test when role is restricted but originating workflow is not allowed
796+
{
797+
Role baseRole = Role.builder(EMPTY_RESTRICTED_INDICES, "role-a")
798+
.add(IndexPrivilege.READ, "index-a")
799+
.workflows(Set.of(WorkflowResolver.SEARCH_APPLICATION_QUERY_WORKFLOW.name()))
800+
.build();
801+
Role limitedBy = Role.builder(EMPTY_RESTRICTED_INDICES, "role-b").add(IndexPrivilege.READ, "index-a").build();
802+
Role role = baseRole.limitedBy(limitedBy);
803+
assertThat(role.forWorkflow(randomFrom(randomAlphaOfLength(9), null, "")), sameInstance(Role.EMPTY_RESTRICTED_BY_WORKFLOW));
804+
assertThat(role.hasWorkflowsRestriction(), equalTo(true));
805+
}
806+
}
807+
770808
public void testGetApplicationPrivilegesByResource() {
771809
final ApplicationPrivilege app1Read = defineApplicationPrivilege("app1", "read", "data:read/*");
772810
final ApplicationPrivilege app1All = defineApplicationPrivilege("app1", "all", "*");

0 commit comments

Comments
 (0)