Skip to content
Merged
Show file tree
Hide file tree
Changes from 28 commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
05f3528
Add Views Security Model
jfreden Jan 13, 2026
db93092
Merge branch 'main' into views/add_security
jfreden Feb 23, 2026
a0a95e4
Update docs/changelog/141050.yaml
jfreden Feb 23, 2026
2aa7cae
Add CCS view tests
jfreden Feb 23, 2026
8c712e1
Merge remote-tracking branch 'upstream/main' into views/add_security
jfreden Feb 23, 2026
75fdad2
fixup! Changelog
jfreden Feb 23, 2026
d246aec
Merge branch 'main' into views/add_security
jfreden Feb 23, 2026
c65ace2
Merge remote-tracking branch 'origin/main' into views/add_security
craigtaverner Feb 24, 2026
42cb008
Merge branch 'main' into views/add_security
jfreden Feb 24, 2026
60f2013
Some code-review updates
craigtaverner Feb 24, 2026
e600412
Merge branch 'views/add_security' of github.com:jfreden/elasticsearch…
craigtaverner Feb 24, 2026
9ed84ed
Merge remote-tracking branch 'origin/main' into views/add_security
craigtaverner Feb 24, 2026
09194af
fixup! Remove comment
jfreden Feb 25, 2026
b4603e7
fixup! Remove todo
jfreden Feb 25, 2026
6fb3e59
Merge branch 'main' into views/add_security
jfreden Feb 25, 2026
65698ea
fixup! Code review
jfreden Feb 26, 2026
4a08b72
Merge remote-tracking branch 'upstream/main' into views/add_security
jfreden Feb 26, 2026
f198a4b
fixup! Bug + code review
jfreden Feb 27, 2026
754a956
Merge remote-tracking branch 'upstream/main' into views/add_security
jfreden Feb 27, 2026
23ec0d3
Add exclusion tests
jfreden Feb 27, 2026
807279f
fixup! generate error message conditionally
jfreden Feb 27, 2026
1005c0d
fixup! CI
jfreden Mar 2, 2026
727caff
fixup! javadoc
jfreden Mar 2, 2026
7378d83
Fix exclusions and add tests
jfreden Mar 2, 2026
2cd6d1b
Merge branch 'main' into views/add_security
jfreden Mar 2, 2026
1672fe5
fixup! Spotless
jfreden Mar 2, 2026
212b61f
Merge branch 'main' into views/add_security
jfreden Mar 3, 2026
d34fc96
Merge branch 'main' into views/add_security
jfreden Mar 4, 2026
2a4c788
Merge branch 'main' into views/add_security
jfreden Mar 4, 2026
16d53ba
fixup! Code review comments
jfreden Mar 5, 2026
1e06a10
Merge branch 'main' into views/add_security
jfreden Mar 5, 2026
e4f5cdd
fixup! Exclusions
jfreden Mar 6, 2026
da6ebda
Merge branch 'main' into views/add_security
jfreden Mar 6, 2026
f93b715
fixup! Added simplification too fast
jfreden Mar 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/changelog/141050.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
area: "ES|QL"
issues: []
pr: 141050
summary: Add Views Security Model
type: enhancement
Original file line number Diff line number Diff line change
Expand Up @@ -531,7 +531,7 @@ public int size(Map<String, IndexAbstraction> lookup) {
}
}
size += failureIndices;
} else {
} else if (IndexAbstraction.Type.DATA_STREAM.equals(indexAbstraction.getType())) {
Comment thread
craigtaverner marked this conversation as resolved.
DataStream parentDataStream = (DataStream) indexAbstraction;
size += parentDataStream.getFailureIndices().size();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,18 @@ public void testAliases() throws Exception {
}
}

public void testViewRewriteDoesNotDropUnauthorizedTargetsWhenMixedWithViews() throws Exception {
expectThrows(ResponseException.class, () -> runESQLCommand("user1", "FROM index-user2 | STATS sum=sum(value)"));
expectThrows(ResponseException.class, () -> runESQLCommand("user1", "FROM index-user1,index-user2 | STATS sum=sum(value)"));
var resp = expectThrows(
ResponseException.class,
() -> runESQLCommand("user1", "FROM view-user1,index-user2 | STATS sum=sum(value)")
);
String errorMessage = EntityUtils.toString(resp.getResponse().getEntity());
assertThat(errorMessage, containsString("Unknown index [index-user2]"));
assertThat(resp.getResponse().getStatusLine().getStatusCode(), equalTo(HttpStatus.SC_BAD_REQUEST));
}

public void testAliasFilter() throws Exception {
for (var index : List.of("first-alias", "first-alias,index-*", "first-*,index-*")) {
Response resp = runESQLCommand("alias_user1", "from " + index + " METADATA _index" + "| KEEP _index, org, value | LIMIT 10");
Expand Down Expand Up @@ -457,6 +469,110 @@ public void testLimitedPrivilege() throws Exception {
assertThat(resp.getResponse().getStatusLine().getStatusCode(), equalTo(HttpStatus.SC_FORBIDDEN));
}

public void testViewRewriteDoesNotDropUnauthorizedTargets() throws Exception {
ResponseException resp = expectThrows(ResponseException.class, () -> runESQLCommand("user1", "FROM view | STATS sum=sum(value)"));
String errorMessage = EntityUtils.toString(resp.getResponse().getEntity());
assertThat(errorMessage, containsString("unauthorized"));
assertThat(errorMessage, containsString("indices [index-user2]"));
assertThat(resp.getResponse().getStatusLine().getStatusCode(), equalTo(HttpStatus.SC_FORBIDDEN));
}

public void testViewRewriteAllUnauthorizedTargetsFails() throws Exception {
createView("test-admin", "other-view-user1", "FROM index-user2 | KEEP value, org");

ResponseException resp = expectThrows(
ResponseException.class,
() -> runESQLCommand("user1", "FROM other-view-user1 | STATS sum=sum(value)")
);
String errorMessage = EntityUtils.toString(resp.getResponse().getEntity());
assertThat(errorMessage, containsString("Unknown index [index-user2]"));
assertThat(resp.getResponse().getStatusLine().getStatusCode(), equalTo(HttpStatus.SC_BAD_REQUEST));
}

public void testViewRewriteMixedUnauthorizedAndMissingTargetsFails() throws Exception {
createView("test-admin", "other-view-user1", "FROM index-user2,missing-view-target | KEEP value, org");

ResponseException resp = expectThrows(
ResponseException.class,
() -> runESQLCommand("user1", "FROM other-view-user1 | STATS sum=sum(value)")
);
String errorMessage = EntityUtils.toString(resp.getResponse().getEntity());
assertThat(errorMessage, containsString("Unknown index [index-user2,missing-view-target]"));
assertThat(resp.getResponse().getStatusLine().getStatusCode(), equalTo(HttpStatus.SC_BAD_REQUEST));
}

public void testViewQueryAuthorized() throws Exception {
Response resp = runESQLCommand("user1", "FROM view-user1 | STATS sum=sum(value)");
assertOK(resp);
Map<String, Object> respMap = entityAsMap(resp);
assertThat(respMap.get("columns"), equalTo(List.of(Map.of("name", "sum", "type", "double"))));
assertThat(respMap.get("values"), equalTo(List.of(List.of(30.0d))));
}

public void testViewWildcardFiltersUnauthorized() throws Exception {
Response resp = runESQLCommand("user1", "FROM view-user* | STATS sum=sum(value)");
assertOK(resp);
Map<String, Object> respMap = entityAsMap(resp);
assertThat(respMap.get("columns"), equalTo(List.of(Map.of("name", "sum", "type", "double"))));
assertThat(respMap.get("values"), equalTo(List.of(List.of(30.0d))));
}

public void testNestedViewResolutionAuthorized() throws Exception {
createView("test-admin", "other-view-user1", "FROM view-user1");
Response resp = runESQLCommand("user1", "FROM other-view-user1 | STATS sum=sum(value)");
assertOK(resp);
Map<String, Object> respMap = entityAsMap(resp);
assertThat(respMap.get("columns"), equalTo(List.of(Map.of("name", "sum", "type", "double"))));
assertThat(respMap.get("values"), equalTo(List.of(List.of(30.0d))));
}

public void testNestedViewInnerViewUnauthorized() throws Exception {
createView("test-admin", "other-view-user1", "FROM view-user2");
ResponseException resp = expectThrows(
ResponseException.class,
() -> runESQLCommand("user1", "FROM other-view-user1 | STATS sum=sum(value)")
);
String errorMessage = EntityUtils.toString(resp.getResponse().getEntity());
assertThat(errorMessage, containsString("Unknown index [view-user2]"));
assertThat(resp.getResponse().getStatusLine().getStatusCode(), equalTo(HttpStatus.SC_BAD_REQUEST));
}

public void testViewDataSelectorResolvesView() throws Exception {
Response resp = runESQLCommand("user1", "FROM view-user1::data | STATS sum=sum(value)");
assertOK(resp);
Map<String, Object> respMap = entityAsMap(resp);
assertThat(respMap.get("columns"), equalTo(List.of(Map.of("name", "sum", "type", "double"))));
assertThat(respMap.get("values"), equalTo(List.of(List.of(30.0d))));
}

public void testViewFailureSelectorNotResolved() throws Exception {
ResponseException resp = expectThrows(
ResponseException.class,
() -> runESQLCommand("user1", "FROM view-user1::failures | STATS sum=sum(value)")
);
assertThat(resp.getResponse().getStatusLine().getStatusCode(), equalTo(HttpStatus.SC_BAD_REQUEST));
}

public void testViewReferencingAliasAuthorized() throws Exception {
createView("test-admin", "other-view-user1", "FROM first-alias");
Response resp = runESQLCommand("user1", "FROM other-view-user1 | STATS sum=sum(value)");
assertOK(resp);
Map<String, Object> respMap = entityAsMap(resp);
assertThat(respMap.get("columns"), equalTo(List.of(Map.of("name", "sum", "type", "double"))));
assertThat(respMap.get("values"), equalTo(List.of(List.of(31.0d))));
}

public void testViewReferencingAliasUnauthorized() throws Exception {
createView("test-admin", "other-view-user2", "FROM first-alias");
ResponseException resp = expectThrows(
ResponseException.class,
() -> runESQLCommand("user2", "FROM other-view-user2 | STATS sum=sum(value)")
);
String errorMessage = EntityUtils.toString(resp.getResponse().getEntity());
assertThat(errorMessage, containsString("Unknown index [first-alias]"));
assertThat(resp.getResponse().getStatusLine().getStatusCode(), equalTo(HttpStatus.SC_BAD_REQUEST));
}

public void testDocumentLevelSecurity() throws Exception {
Response resp = runESQLCommand("user3", "from index | stats sum=sum(value)");
assertOK(resp);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ user1:
- cluster:monitor/main
- manage_enrich
indices:
- names: ['index-user1', 'view-user1', "view", 'other-view-user1', 'index', "test-enrich" ]
- names: ['index-user1', 'view-user1', "view", 'other-view-user1', 'index', "test-enrich", 'first-alias' ]
privileges:
- read
- write
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
package org.elasticsearch.xpack.esql.action;

import org.elasticsearch.action.ActionListener;
import org.elasticsearch.action.ActionRequestValidationException;
import org.elasticsearch.action.ActionResponse;
import org.elasticsearch.action.ActionType;
import org.elasticsearch.action.IndicesRequest;
import org.elasticsearch.action.ResolvedIndexExpressions;
import org.elasticsearch.action.support.ActionFilters;
import org.elasticsearch.action.support.IndicesOptions;
import org.elasticsearch.action.support.TransportAction;
import org.elasticsearch.action.support.local.LocalClusterStateRequest;
import org.elasticsearch.action.support.local.TransportLocalProjectMetadataAction;
import org.elasticsearch.cluster.ProjectState;
import org.elasticsearch.cluster.block.ClusterBlockException;
import org.elasticsearch.cluster.block.ClusterBlockLevel;
import org.elasticsearch.cluster.metadata.IndexNameExpressionResolver;
import org.elasticsearch.cluster.metadata.View;
import org.elasticsearch.cluster.project.ProjectResolver;
import org.elasticsearch.cluster.service.ClusterService;
import org.elasticsearch.common.io.stream.StreamOutput;
import org.elasticsearch.common.util.concurrent.EsExecutors;
import org.elasticsearch.core.TimeValue;
import org.elasticsearch.injection.guice.Inject;
import org.elasticsearch.tasks.CancellableTask;
import org.elasticsearch.tasks.Task;
import org.elasticsearch.tasks.TaskId;
import org.elasticsearch.transport.TransportService;
import org.elasticsearch.xpack.esql.view.ViewResolutionService;

import java.io.IOException;
import java.util.Arrays;
import java.util.Map;

public class EsqlResolveViewAction extends TransportLocalProjectMetadataAction<
EsqlResolveViewAction.Request,
EsqlResolveViewAction.Response> {
public static final String NAME = "indices:data/read/esql/resolve_views";
public static final ActionType<EsqlResolveViewAction.Response> TYPE = new ActionType<>(NAME);

private final ViewResolutionService viewResolutionService;

@Inject
public EsqlResolveViewAction(
TransportService transportService,
ActionFilters actionFilters,
IndexNameExpressionResolver indexNameExpressionResolver,
ClusterService clusterService,
ProjectResolver projectResolver
) {
// TODO replace DIRECT_EXECUTOR_SERVICE when removing workaround for https://github.com/elastic/elasticsearch/issues/97916
super(NAME, actionFilters, transportService.getTaskManager(), clusterService, EsExecutors.DIRECT_EXECUTOR_SERVICE, projectResolver);
this.viewResolutionService = new ViewResolutionService(indexNameExpressionResolver);
}

@Override
protected ClusterBlockException checkBlock(Request request, ProjectState state) {
return state.blocks().globalBlockedException(ClusterBlockLevel.METADATA_READ);
}

@Override
protected void localClusterStateOperation(Task task, Request request, ProjectState project, ActionListener<Response> listener) {
var result = viewResolutionService.resolveViews(
project,
request.indices(),
request.indicesOptions(),
request.getResolvedIndexExpressions()
);
listener.onResponse(new EsqlResolveViewAction.Response(result.views(), result.resolvedIndexExpressions()));
}

public static class Request extends LocalClusterStateRequest implements IndicesRequest.Replaceable {

private String[] indices = new String[0];
private ResolvedIndexExpressions resolvedIndexExpressions;
private static final IndicesOptions VIEW_INDICES_OPTIONS = IndicesOptions.builder()
.wildcardOptions(IndicesOptions.WildcardOptions.builder().resolveViews(true).allowEmptyExpressions(true))
.concreteTargetOptions(IndicesOptions.ConcreteTargetOptions.ALLOW_UNAVAILABLE_TARGETS)
.build();

public Request(TimeValue masterTimeout) {
super(masterTimeout);
}

@Override
public IndicesRequest indices(String... indices) {
this.indices = indices;
return this;
}

@Override
public Task createTask(long id, String type, String action, TaskId parentTaskId, Map<String, String> headers) {
return new CancellableTask(id, type, action, getDescription(), parentTaskId, headers);
}

@Override
public String[] indices() {
return indices;
}

@Override
public IndicesOptions indicesOptions() {
return VIEW_INDICES_OPTIONS;
}

@Override
public ActionRequestValidationException validate() {
return null;
}

@Override
public String toString() {
return "EsqlResolveViewAction.Request={indices:" + Arrays.toString(indices) + "}";
}

@Override
public void setResolvedIndexExpressions(ResolvedIndexExpressions expressions) {
this.resolvedIndexExpressions = expressions;
}

@Override
public ResolvedIndexExpressions getResolvedIndexExpressions() {
return this.resolvedIndexExpressions;
}
}

public static class Response extends ActionResponse {
private final View[] views;
private final ResolvedIndexExpressions resolvedIndexExpressions;

public Response(View[] views, ResolvedIndexExpressions resolvedIndexExpressions) {
this.views = views;
this.resolvedIndexExpressions = resolvedIndexExpressions;
}

@Override
public void writeTo(StreamOutput out) throws IOException {
TransportAction.localOnly();
}

public View[] views() {
return views;
}

public ResolvedIndexExpressions getResolvedIndexExpressions() {
return resolvedIndexExpressions;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
*/
package org.elasticsearch.xpack.esql.core.tree;

import org.elasticsearch.action.ActionListener;
import org.elasticsearch.action.support.SubscribableListener;
import org.elasticsearch.common.io.stream.NamedWriteable;
import org.elasticsearch.xpack.esql.core.QlIllegalArgumentException;
import org.elasticsearch.xpack.esql.core.expression.NameId;
Expand Down Expand Up @@ -273,6 +275,50 @@ public <E extends T> T transformDown(Predicate<Node<?>> nodePredicate, Function<
return transformDown((t) -> (nodePredicate.test(t) ? rule.apply((E) t) : t));
}

/**
* Asynchronous variant of {@link #transformDown(Function)} that allows the transformation rule to perform
* async I/O operations (e.g., transport actions) without blocking the caller thread.
* <p>
* Children are transformed sequentially, not concurrently, one after another in order.
* This method is intended for cases where async I/O is needed during transformation, not for parallel
* processing.
*/
@SuppressWarnings("unchecked")
public void transformDown(BiConsumer<? super T, ActionListener<T>> rule, ActionListener<T> listener) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: I'm deferring to the other reviewers here since it's ES|QL code. It looks complicated but I'm assuming there's not way around it.

rule.accept((T) this, listener.delegateFailureAndWrap((originalListener, root) -> {
Node<T> node = this.equals(root) ? this : root;
node.transformChildren((child, childListener) -> child.transformDown(rule, childListener), originalListener);
}));
}

@SuppressWarnings("unchecked")
protected void transformChildren(BiConsumer<T, ActionListener<T>> traversalOperation, ActionListener<T> listener) {
if (children.isEmpty()) {
listener.onResponse((T) this);
return;
}

final Holder<List<T>> updatedChildren = new Holder<>();
SubscribableListener<Void> chain = SubscribableListener.newForked(l -> l.onResponse(null));
for (int i = 0; i < children.size(); i++) {
var index = i;
var child = children.get(index);
chain = chain.andThen(originalListener -> {
traversalOperation.accept(child, originalListener.delegateFailureAndWrap((o, maybeTransformed) -> {
if (maybeTransformed.equals(child) == false) {
if (updatedChildren.get() == null) {
updatedChildren.set(new ArrayList<>(children));
}
updatedChildren.get().set(index, maybeTransformed);
}
o.onResponse(null);
}));
});
}
chain.andThenApply(ignored -> updatedChildren.get() == null ? (T) this : replaceChildrenSameSize(updatedChildren.get()))
.addListener(listener);
}

@SuppressWarnings("unchecked")
public T transformUp(Function<? super T, ? extends T> rule) {
T transformed = transformChildren(child -> child.transformUp(rule));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@
import org.elasticsearch.xpack.esql.action.EsqlListQueriesAction;
import org.elasticsearch.xpack.esql.action.EsqlQueryAction;
import org.elasticsearch.xpack.esql.action.EsqlResolveFieldsAction;
import org.elasticsearch.xpack.esql.action.EsqlResolveViewAction;
import org.elasticsearch.xpack.esql.action.EsqlSearchShardsAction;
import org.elasticsearch.xpack.esql.action.RestEsqlAsyncQueryAction;
import org.elasticsearch.xpack.esql.action.RestEsqlDeleteAsyncResultAction;
Expand Down Expand Up @@ -270,7 +271,7 @@ public Collection<?> createComponents(PluginServices services) {
);
if (ESQL_VIEWS_FEATURE_FLAG.isEnabled()) {
components = new ArrayList<>(components);
components.add(new ViewResolver(services.clusterService(), services.projectResolver()));
components.add(new ViewResolver(services.clusterService(), services.projectResolver(), services.client()));
components.add(new ViewService(services.clusterService()));
}
return components;
Expand Down Expand Up @@ -343,6 +344,7 @@ public List<ActionHandler> getActions() {
List.of(
new ActionHandler(PutViewAction.INSTANCE, TransportPutViewAction.class),
new ActionHandler(DeleteViewAction.INSTANCE, TransportDeleteViewAction.class),
new ActionHandler(EsqlResolveViewAction.TYPE, EsqlResolveViewAction.class),
new ActionHandler(GetViewAction.INSTANCE, TransportGetViewAction.class)
)
);
Expand Down
Loading