Skip to content

Commit 44bccc4

Browse files
committed
Add read_failures privilege for authorizing failure store
This commit adds the `read_failures` privilege and the logic supporting that privilege. The `read_failures` privilege enables read access to failure store indices owned by data streams named in the `indices` field of an indices privileges group, without implying `read` access to that data stream's "normal" backing indices. This is a bit of a mismatch with the existing privilege model, which authorizes actions and indices orthogonally. As of this change, in order to fully authorize an action, *both* action name and requested indices must be considered. Non-read actions to failure store indices, such as management calls, are authorized the same as backing indices; authorization will be granted to manage failure store indices if the user has permission to manage the owning data stream. It is only data visibility that is gated behind the new permission.
1 parent 80e8017 commit 44bccc4

File tree

13 files changed

+356
-172
lines changed

13 files changed

+356
-172
lines changed

docs/reference/rest-api/security/get-builtin-privileges.asciidoc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,7 @@ A successful call returns an object with "cluster", "index", and "remote_cluster
156156
"read",
157157
"read_cross_cluster",
158158
"view_index_metadata",
159+
"read_failures",
159160
"write"
160161
],
161162
"remote_cluster" : [

server/src/main/java/org/elasticsearch/cluster/metadata/IndexNameExpressionResolver.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1719,7 +1719,7 @@ private static Set<ResolvedExpression> expandToOpenClosed(
17191719
for (int i = 0, n = indexAbstraction.getIndices().size(); i < n; i++) {
17201720
Index index = indexAbstraction.getIndices().get(i);
17211721
IndexMetadata indexMetadata = context.state.metadata().index(index);
1722-
if (indexMetadata.getState() != excludeState) {
1722+
if (indexMetadata != null && indexMetadata.getState() != excludeState) {
17231723
resources.add(
17241724
new ResolvedExpression(
17251725
index.getName(),

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

Lines changed: 205 additions & 153 deletions
Large diffs are not rendered by default.

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
import org.apache.lucene.util.automaton.Automaton;
1111
import org.elasticsearch.TransportVersion;
12+
import org.elasticsearch.action.support.IndexComponentSelector;
1213
import org.elasticsearch.cluster.metadata.IndexAbstraction;
1314
import org.elasticsearch.common.bytes.BytesReference;
1415
import org.elasticsearch.common.util.set.Sets;

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

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,11 @@ public final class IndexPrivilege extends Privilege {
8181
ResolveIndexAction.NAME,
8282
TransportResolveClusterAction.NAME
8383
);
84+
// This is a special case: read_failures acts like `read` *only* for failure store indices in authorized data streams.
85+
// This internal action is not used, but having it makes automaton subset checks work as expected with this privilege.
86+
private static final Automaton READ_FAILURES_AUTOMATON = patterns(
87+
"internal:special/read_failures"
88+
);
8489
private static final Automaton READ_CROSS_CLUSTER_AUTOMATON = patterns(
8590
"internal:transport/proxy/indices:data/read/*",
8691
TransportClusterSearchShardsAction.TYPE.name(),
@@ -178,7 +183,11 @@ public final class IndexPrivilege extends Privilege {
178183

179184
public static final IndexPrivilege NONE = new IndexPrivilege("none", Automatons.EMPTY);
180185
public static final IndexPrivilege ALL = new IndexPrivilege("all", ALL_AUTOMATON);
181-
public static final IndexPrivilege READ = new IndexPrivilege("read", READ_AUTOMATON);
186+
public static final String READ_PRIVILEGE_NAME = "read";
187+
public static final IndexPrivilege READ = new IndexPrivilege(READ_PRIVILEGE_NAME, READ_AUTOMATON);
188+
public static final String READ_FAILURES_PRIVILEGE_NAME = "read_failures";
189+
// read_failures is a special case - it should act like `read`, but adjusted to only allow access to failure indices
190+
public static final IndexPrivilege READ_FAILURES = new IndexPrivilege(READ_FAILURES_PRIVILEGE_NAME, READ_FAILURES_AUTOMATON);
182191
public static final IndexPrivilege READ_CROSS_CLUSTER = new IndexPrivilege("read_cross_cluster", READ_CROSS_CLUSTER_AUTOMATON);
183192
public static final IndexPrivilege CREATE = new IndexPrivilege("create", CREATE_AUTOMATON);
184193
public static final IndexPrivilege INDEX = new IndexPrivilege("index", INDEX_AUTOMATON);
@@ -221,6 +230,7 @@ public final class IndexPrivilege extends Privilege {
221230
entry("create_index", CREATE_INDEX),
222231
entry("monitor", MONITOR),
223232
entry("read", READ),
233+
entry("read_failures", READ_FAILURES),
224234
entry("index", INDEX),
225235
entry("delete", DELETE),
226236
entry("write", WRITE),

x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/authz/privilege/IndexPrivilegeTests.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ public void testOrderingOfPrivilegeNames() throws Exception {
5858
}
5959

6060
public void testFindPrivilegesThatGrant() {
61-
assertThat(findPrivilegesThatGrant(TransportSearchAction.TYPE.name()), equalTo(List.of("read", "all")));
61+
assertThat(findPrivilegesThatGrant(TransportSearchAction.TYPE.name()), equalTo(List.of("read", "read_failures", "all")));
6262
assertThat(findPrivilegesThatGrant(TransportIndexAction.NAME), equalTo(List.of("create_doc", "create", "index", "write", "all")));
6363
assertThat(findPrivilegesThatGrant(TransportUpdateAction.NAME), equalTo(List.of("index", "write", "all")));
6464
assertThat(findPrivilegesThatGrant(TransportDeleteAction.NAME), equalTo(List.of("delete", "write", "all")));

x-pack/plugin/security/qa/multi-cluster/src/javaRestTest/java/org/elasticsearch/xpack/remotecluster/RemoteClusterSecurityEsqlIT.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -676,7 +676,7 @@ public void testCrossClusterQueryWithOnlyRemotePrivs() throws Exception {
676676
error.getMessage(),
677677
containsString(
678678
"action [indices:data/read/esql] is unauthorized for user [remote_search_user] with effective roles [remote_search], "
679-
+ "this action is granted by the index privileges [read,read_cross_cluster,all]"
679+
+ "this action is granted by the index privileges [read,read_failures,read_cross_cluster,all]"
680680
)
681681
);
682682

x-pack/plugin/security/qa/multi-cluster/src/javaRestTest/java/org/elasticsearch/xpack/remotecluster/RemoteClusterSecurityFcActionAuthorizationIT.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -435,7 +435,7 @@ public void testUpdateCrossClusterApiKey() throws Exception {
435435
+ "for user [foo] with assigned roles [role] authenticated by API key id ["
436436
+ apiKeyId
437437
+ "] of user [test_user] on indices [index], this action is granted by the index privileges "
438-
+ "[view_index_metadata,manage,read,all]"
438+
+ "[view_index_metadata,manage,read,read_failures,all]"
439439
)
440440
);
441441

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,7 @@ public void testWorkflowsRestrictionAllowsAccess() throws IOException {
187187
+ apiKeyId
188188
+ "] of user ["
189189
+ WORKFLOW_API_KEY_USER
190-
+ "] on indices [my-app-b], this action is granted by the index privileges [read,all]"
190+
+ "] on indices [my-app-b], this action is granted by the index privileges [read,read_failures,all]"
191191
)
192192
);
193193
assertThat(e.getMessage(), not(containsString("access restricted by workflow")));

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

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,27 +8,42 @@
88
package org.elasticsearch.integration;
99

1010
import org.elasticsearch.ElasticsearchSecurityException;
11+
import org.elasticsearch.action.DocWriteRequest;
12+
import org.elasticsearch.action.admin.indices.get.GetIndexRequest;
13+
import org.elasticsearch.action.admin.indices.refresh.RefreshRequest;
1114
import org.elasticsearch.action.admin.indices.rollover.RolloverRequest;
1215
import org.elasticsearch.action.admin.indices.stats.IndicesStatsRequest;
1316
import org.elasticsearch.action.admin.indices.template.put.TransportPutComposableIndexTemplateAction;
17+
import org.elasticsearch.action.bulk.BulkItemResponse;
18+
import org.elasticsearch.action.bulk.BulkRequest;
19+
import org.elasticsearch.action.bulk.BulkResponse;
1420
import org.elasticsearch.action.datastreams.CreateDataStreamAction;
1521
import org.elasticsearch.action.datastreams.ModifyDataStreamsAction;
22+
import org.elasticsearch.action.index.IndexRequest;
1623
import org.elasticsearch.action.search.SearchRequest;
1724
import org.elasticsearch.cluster.ClusterState;
1825
import org.elasticsearch.cluster.ClusterStateUpdateTask;
1926
import org.elasticsearch.cluster.metadata.ComposableIndexTemplate;
2027
import org.elasticsearch.cluster.metadata.DataStream;
2128
import org.elasticsearch.cluster.metadata.DataStreamAction;
29+
import org.elasticsearch.cluster.metadata.DataStreamFailureStore;
30+
import org.elasticsearch.cluster.metadata.DataStreamOptions;
2231
import org.elasticsearch.cluster.metadata.Metadata;
32+
import org.elasticsearch.cluster.metadata.ResettableValue;
33+
import org.elasticsearch.cluster.metadata.Template;
2334
import org.elasticsearch.cluster.service.ClusterService;
35+
import org.elasticsearch.common.compress.CompressedXContent;
36+
import org.elasticsearch.core.Strings;
2437
import org.elasticsearch.datastreams.DataStreamsPlugin;
2538
import org.elasticsearch.index.Index;
2639
import org.elasticsearch.index.mapper.extras.MapperExtrasPlugin;
2740
import org.elasticsearch.plugins.Plugin;
41+
import org.elasticsearch.rest.RestStatus;
2842
import org.elasticsearch.test.SecurityIntegTestCase;
2943
import org.elasticsearch.test.SecuritySettingsSource;
3044
import org.elasticsearch.test.SecuritySettingsSourceField;
3145
import org.elasticsearch.transport.netty4.Netty4Plugin;
46+
import org.elasticsearch.xcontent.XContentType;
3247
import org.elasticsearch.xpack.security.LocalStateSecurity;
3348
import org.elasticsearch.xpack.wildcard.Wildcard;
3449

@@ -43,6 +58,7 @@
4358
import static org.elasticsearch.xpack.core.security.authc.support.UsernamePasswordToken.basicAuthHeaderValue;
4459
import static org.hamcrest.Matchers.equalTo;
4560
import static org.hamcrest.Matchers.hasSize;
61+
import static org.hamcrest.Matchers.nullValue;
4662

4763
public class DataStreamSecurityIT extends SecurityIntegTestCase {
4864

@@ -51,6 +67,31 @@ protected Collection<Class<? extends Plugin>> nodePlugins() {
5167
return List.of(LocalStateSecurity.class, Netty4Plugin.class, MapperExtrasPlugin.class, DataStreamsPlugin.class, Wildcard.class);
5268
}
5369

70+
@Override
71+
protected String configUsers() {
72+
final String usersPasswdHashed = new String(
73+
getFastStoredHashAlgoForTests().hash(SecuritySettingsSourceField.TEST_PASSWORD_SECURE_STRING)
74+
);
75+
return super.configUsers() + "only_failures:" + usersPasswdHashed + "\n";
76+
}
77+
78+
@Override
79+
protected String configUsersRoles() {
80+
return super.configUsersRoles() + "only_failures:only_failures\n";
81+
}
82+
83+
@Override
84+
protected String configRoles() {
85+
// role that has analyze indices privileges only
86+
return Strings.format("""
87+
%s
88+
only_failures:
89+
indices:
90+
- names: '*'
91+
privileges: [ 'read_failures', 'write' ]
92+
""", super.configRoles());
93+
}
94+
5495
public void testRemoveGhostReference() throws Exception {
5596
var headers = Map.of(
5697
BASIC_AUTH_HEADER,
@@ -142,4 +183,61 @@ public void onFailure(Exception e) {
142183
assertThat(indicesStatsResponse.getIndices().size(), equalTo(shouldBreakIndexName ? 1 : 2));
143184
}
144185

186+
public void testFailureStoreAuthorziation() throws Exception {
187+
var adminHeaders = Map.of(
188+
BASIC_AUTH_HEADER,
189+
basicAuthHeaderValue(SecuritySettingsSource.TEST_USER_NAME, SecuritySettingsSourceField.TEST_PASSWORD_SECURE_STRING)
190+
);
191+
final var adminClient = client().filterWithHeader(adminHeaders);
192+
var onlyFailHeaders = Map.of(
193+
BASIC_AUTH_HEADER,
194+
basicAuthHeaderValue(SecuritySettingsSource.TEST_USER_NAME, SecuritySettingsSourceField.TEST_PASSWORD_SECURE_STRING)
195+
);
196+
final var failuresClient = client().filterWithHeader(onlyFailHeaders);
197+
198+
var putTemplateRequest = new TransportPutComposableIndexTemplateAction.Request("id");
199+
putTemplateRequest.indexTemplate(
200+
ComposableIndexTemplate.builder()
201+
.indexPatterns(List.of("stuff-*"))
202+
.template(
203+
Template.builder()
204+
.mappings(CompressedXContent.fromJSON("{\"properties\": {\"code\": {\"type\": \"integer\"}}}"))
205+
.dataStreamOptions(
206+
new DataStreamOptions.Template(
207+
ResettableValue.create(new DataStreamFailureStore.Template(ResettableValue.create(true)))
208+
)
209+
)
210+
)
211+
.dataStreamTemplate(new ComposableIndexTemplate.DataStreamTemplate(false, false))
212+
.build()
213+
);
214+
assertAcked(adminClient.execute(TransportPutComposableIndexTemplateAction.TYPE, putTemplateRequest).actionGet());
215+
216+
String dataStreamName = "stuff-es";
217+
var request = new CreateDataStreamAction.Request(TEST_REQUEST_TIMEOUT, TEST_REQUEST_TIMEOUT, dataStreamName);
218+
assertAcked(adminClient.execute(CreateDataStreamAction.INSTANCE, request).actionGet());
219+
220+
BulkRequest bulkRequest = new BulkRequest();
221+
bulkRequest.add(
222+
new IndexRequest(dataStreamName).opType(DocWriteRequest.OpType.CREATE)
223+
.source("{\"code\": \"well this aint right\"}", XContentType.JSON),
224+
new IndexRequest(dataStreamName).opType(DocWriteRequest.OpType.CREATE).source("{\"@timestamp\": \"2015-01-01T12:10:30Z\", \"code\": 404}", XContentType.JSON)
225+
);
226+
BulkResponse bulkResponse = adminClient.bulk(bulkRequest).actionGet();
227+
assertThat(bulkResponse.getItems().length, equalTo(2));
228+
String backingIndexPrefix = DataStream.BACKING_INDEX_PREFIX + dataStreamName;
229+
String failureIndexPrefix = DataStream.FAILURE_STORE_PREFIX + dataStreamName;
230+
231+
for (BulkItemResponse itemResponse : bulkResponse) {
232+
assertThat(itemResponse.getFailure(), nullValue());
233+
assertThat(itemResponse.status(), equalTo(RestStatus.CREATED));
234+
// assertThat(itemResponse.getIndex(), anyOf(startsWith(backingIndexPrefix), startsWith(failureIndexPrefix)));
235+
}
236+
237+
indicesAdmin().refresh(new RefreshRequest(dataStreamName)).actionGet();
238+
var getResp = failuresClient.admin().indices().getIndex(new GetIndexRequest().indices(dataStreamName + "::*"));
239+
var searchResponse = failuresClient.prepareSearch(dataStreamName + "::failures").get();
240+
assertThat(searchResponse.getHits().getTotalHits().value(), equalTo(1L));
241+
}
242+
145243
}

0 commit comments

Comments
 (0)