Skip to content
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
464a468
Update View CRUD Actions to be Index Actions
jfreden Jan 30, 2026
93a1956
Update docs/changelog/141570.yaml
jfreden Feb 3, 2026
27f3801
fixup! Simplify
jfreden Feb 4, 2026
2ef9ff4
fixup! changelog
jfreden Feb 4, 2026
5cccaea
[CI] Auto commit changes from spotless
Feb 4, 2026
3ec5ea2
Update x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/…
jfreden Feb 4, 2026
c0c867b
Update x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/…
jfreden Feb 4, 2026
aaf5c60
fixup! Code review comment
jfreden Feb 4, 2026
beddddf
Remove ServerlessScope(Scope.PUBLIC) from view CRUD
jfreden Feb 5, 2026
b09d35b
fixup! Code review comments
jfreden Feb 5, 2026
3faf350
Merge branch 'main' into views/update_actions
jfreden Feb 5, 2026
3a7709a
fixup! CI error since views not in serverless
jfreden Feb 5, 2026
eaf9983
Merge branch 'main' into views/update_actions
jfreden Feb 5, 2026
47fd65c
Merge branch 'main' into views/update_actions
jfreden Feb 5, 2026
ec8db54
fixup! Code review comments
jfreden Feb 6, 2026
e91761b
[CI] Auto commit changes from spotless
Feb 6, 2026
4bb756f
fixup! Test
jfreden Feb 6, 2026
ab286ff
fixup! Code review
jfreden Feb 6, 2026
0342a3d
Merge remote-tracking branch 'upstream/main' into views/update_actions
jfreden Feb 6, 2026
38af812
fixup! Array indexing
jfreden Feb 6, 2026
0328bc4
fixup! CI
jfreden Feb 6, 2026
1ffbf25
Try to disable MixedClusterEsqlSpecIT if new actions not available
jfreden Feb 6, 2026
bbfe727
fixup! Update test to require views_crud_as_index_actions
jfreden Feb 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/141570.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
area: "Authorization"
issues: []
pr: 141570
summary: Update View CRUD Actions to be Index Actions
type: enhancement
Original file line number Diff line number Diff line change
Expand Up @@ -97,19 +97,21 @@ public XContentBuilder toXContent(XContentBuilder builder, Params params) throws
* @param resolveAliases, aliases will be included in the result, if false we treat them like they do not exist
* @param allowEmptyExpressions, when an expression does not result in any indices, if false it throws an error if true it treats it as
* an empty result
* @param resolveViews, views will be included in the result, if false we treat them like they do not exist
Copy link
Contributor Author

Choose a reason for hiding this comment

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

IndicesOptions.wildcardOptions defines how wildcard expressions should be expanded. The new resolveViews wildcard option is added to allow index expressions to be resolved to views by the authorization engine. In addition to that the new flag on the wilcard index option should:

  • Not be populated from REST requests, since there is no support for it in the APIs (it's hardcoded to always resolve views where it's used)
  • Not be populated when transport requests are serialized (writeIndicesOptions) or deserialized (readIndicesOptions) since it's only used for local actions (this may change if delete starts supporting multi-target syntax since the request will be sent to the master node and authorized there).

*/
public record WildcardOptions(
boolean matchOpen,
boolean matchClosed,
boolean includeHidden,
boolean resolveAliases,
boolean allowEmptyExpressions
boolean allowEmptyExpressions,
boolean resolveViews
) implements ToXContentFragment {

public static final String EXPAND_WILDCARDS = "expand_wildcards";
public static final String ALLOW_NO_INDICES = "allow_no_indices";

public static final WildcardOptions DEFAULT = new WildcardOptions(true, false, false, true, true);
public static final WildcardOptions DEFAULT = new WildcardOptions(true, false, false, true, true, false);

public static WildcardOptions parseParameters(Object expandWildcards, Object allowNoIndices, WildcardOptions defaultOptions) {
if (expandWildcards == null && allowNoIndices == null) {
Expand Down Expand Up @@ -178,6 +180,7 @@ public static class Builder {
private boolean includeHidden;
private boolean resolveAliases;
private boolean allowEmptyExpressions;
private boolean resolveViews;

Builder() {
this(DEFAULT);
Expand All @@ -189,6 +192,7 @@ public static class Builder {
includeHidden = options.includeHidden;
resolveAliases = options.resolveAliases;
allowEmptyExpressions = options.allowEmptyExpressions;
resolveViews = options.resolveViews;
}

/**
Expand Down Expand Up @@ -244,6 +248,14 @@ public Builder matchNone() {
return this;
}

/**
* Resolve views. Defaults to false
*/
public Builder resolveViews(boolean resolveViews) {
this.resolveViews = resolveViews;
return this;
}

/**
* Maximises the resolution of indices, we will match open, closed and hidden targets.
*/
Expand Down Expand Up @@ -281,7 +293,7 @@ public Builder expandStates(String[] expandStates) {
}

public WildcardOptions build() {
return new WildcardOptions(matchOpen, matchClosed, includeHidden, resolveAliases, allowEmptyExpressions);
return new WildcardOptions(matchOpen, matchClosed, includeHidden, resolveAliases, allowEmptyExpressions, resolveViews);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -308,8 +308,7 @@ private static boolean isIndexVisible(
throw new IllegalStateException("could not resolve index abstraction [" + index + "]");
}
if (indexAbstraction.getType() == IndexAbstraction.Type.VIEW) {
// TODO: perhaps revisit this in the future if we make views "visible" or "hidden"?
return false;
return indicesOptions.wildcardOptions().resolveViews();
}
final boolean isHidden = indexAbstraction.isHidden();
boolean isVisible = isWildcardExpression == false
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -725,6 +725,10 @@ public static boolean shouldIncludeFailureIndices(IndicesOptions indicesOptions,
return indicesOptions.includeFailureIndices();
}

public static boolean shouldPreserveViews(IndicesOptions indicesOptions) {
return indicesOptions.wildcardOptions().resolveViews();
}

private static boolean resolvesToMoreThanOneIndex(IndexAbstraction indexAbstraction, Context context, ResolvedExpression expression) {
if (indexAbstraction.getType() == Type.ALIAS && indexAbstraction.isDataStreamRelated()) {
// We inline this logic instead of calling aliasDataStreams because we want to return as soon as we've identified that we have
Expand Down Expand Up @@ -1788,7 +1792,7 @@ private static boolean shouldExpandToIndexAbstraction(
String wildcardExpression,
IndexAbstraction indexAbstraction
) {
if (indexAbstraction.getType() == Type.VIEW) {
if (shouldPreserveViews(context.getOptions()) == false && indexAbstraction.getType() == Type.VIEW) {
return false;
}
if (context.getOptions().ignoreAliases() && indexAbstraction.getType() == Type.ALIAS) {
Expand Down Expand Up @@ -1843,8 +1847,9 @@ private static Set<ResolvedExpression> expandToOpenClosed(
} else if (context.isPreserveDataStreams() && indexAbstraction.getType() == Type.DATA_STREAM) {
resources.add(new ResolvedExpression(indexAbstraction.getName(), selector));
} else if (indexAbstraction.getType() == Type.VIEW) {
// a view cannot expand to any indices, return an empty set
return Set.of();
if (shouldPreserveViews(context.getOptions())) {
resources.add(new ResolvedExpression(indexAbstraction.getName(), selector));
}
} else {
if (shouldIncludeRegularIndices(context.getOptions(), selector)) {
for (int i = 0, n = indexAbstraction.getIndices().size(); i < n; i++) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1851,7 +1851,7 @@ static SortedMap<String, IndexAbstraction> buildIndicesLookup(
ViewMetadata viewMetadata,
ImmutableOpenMap<String, IndexMetadata> indices
) {
if (indices.isEmpty()) {
if (indices.isEmpty() && viewMetadata.views().isEmpty()) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Is the reason that views are included here but data streams are not is that views can exist without indices whereas data streams can't?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, I got some strange results when testing views without creating indices first. Since a view can be valid and even return results without referencing an index, we need this check.

return Collections.emptySortedMap();
}
Map<String, IndexAbstraction> indicesLookup = new HashMap<>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,8 @@ public void testToXContent() throws IOException {
randomBoolean(),
randomBoolean(),
defaultResolveAliasForThisRequest,
randomBoolean()
randomBoolean(),
false // Specifying views in the create snapshot request is not supported
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Specifying views in a restore/create snapshot request is currently not part of the views feature. Not sure if we want to support this?

Copy link
Member

Choose a reason for hiding this comment

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

Views are already part of snapshots in that we capture all views as part of the global state in a snapshot, from

I think eventually the ESQL team will need to decide how to treat views individually, since they cannot be selected currently other than all-or-nothing.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@craigtaverner FYI might be useful with an issue/task for this.

)
)
.gatekeeperOptions(IndicesOptions.GatekeeperOptions.builder().allowSelectors(false).includeFailureIndices(true).build())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,6 @@
import java.util.Map;

import static org.hamcrest.Matchers.containsString;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;

public class RestoreSnapshotRequestTests extends AbstractWireSerializingTestCase<RestoreSnapshotRequest> {
private RestoreSnapshotRequest randomState(RestoreSnapshotRequest instance) {
Expand Down Expand Up @@ -89,7 +87,8 @@ private RestoreSnapshotRequest randomState(RestoreSnapshotRequest instance) {
randomBoolean(),
randomBoolean(),
instance.indicesOptions().ignoreAliases() == false,
randomBoolean()
randomBoolean(),
false // Specifying views in the restore snapshot request is not supported
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Specifying views in a restore/create snapshot request is currently not part of the views feature. Not sure if we want to support this?

)
)
.gatekeeperOptions(IndicesOptions.GatekeeperOptions.builder().allowSelectors(false).includeFailureIndices(true).build())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,7 @@ public void testToXContent() throws IOException {
randomBoolean(),
randomBoolean(),
randomBoolean(),
randomBoolean(),
randomBoolean()
);
GatekeeperOptions gatekeeperOptions = new GatekeeperOptions(
Expand Down Expand Up @@ -384,6 +385,7 @@ public void testFromXContent() throws IOException {
randomBoolean(),
randomBoolean(),
randomBoolean(),
randomBoolean(),
randomBoolean()
);
ConcreteTargetOptions concreteTargetOptions = new ConcreteTargetOptions(randomBoolean());
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/*
* 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.core.esql;

/**
* Exposes ES|QL view action names for RBACEngine.
*/
public class EsqlViewActionNames {
public static final String ESQL_PUT_VIEW_ACTION_NAME = "indices:admin/esql/view/put";
public static final String ESQL_GET_VIEW_ACTION_NAME = "indices:admin/esql/view/get";
public static final String ESQL_DELETE_VIEW_ACTION_NAME = "indices:admin/esql/view/delete";
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
import org.elasticsearch.xpack.core.ccr.action.ForgetFollowerAction;
import org.elasticsearch.xpack.core.ccr.action.PutFollowAction;
import org.elasticsearch.xpack.core.ccr.action.UnfollowAction;
import org.elasticsearch.xpack.core.esql.EsqlViewActionNames;
import org.elasticsearch.xpack.core.ilm.action.ExplainLifecycleAction;
import org.elasticsearch.xpack.core.inference.action.GetInferenceFieldsInternalAction;
import org.elasticsearch.xpack.core.rollup.action.GetRollupIndexCapsAction;
Expand Down Expand Up @@ -194,6 +195,10 @@ public final class IndexPrivilege extends Privilege {
"internal:transport/proxy/indices:internal/admin/ccr/restore/session/clear*",
"internal:transport/proxy/indices:internal/admin/ccr/restore/file_chunk/get*"
);
private static final Automaton CREATE_VIEW_AUTOMATON = patterns(EsqlViewActionNames.ESQL_PUT_VIEW_ACTION_NAME);
private static final Automaton READ_VIEW_METADATA_AUTOMATON = patterns(EsqlViewActionNames.ESQL_GET_VIEW_ACTION_NAME);
private static final Automaton DELETE_VIEW_AUTOMATON = patterns(EsqlViewActionNames.ESQL_DELETE_VIEW_ACTION_NAME);
private static final Automaton MANAGE_VIEW_AUTOMATON = patterns("indices:admin/esql/view*");

public static final IndexPrivilege NONE = new IndexPrivilege("none", Automatons.EMPTY);
public static final IndexPrivilege ALL = new IndexPrivilege("all", ALL_AUTOMATON, IndexComponentSelectorPredicate.ALL);
Expand Down Expand Up @@ -231,6 +236,10 @@ public final class IndexPrivilege extends Privilege {
"cross_cluster_replication_internal",
CROSS_CLUSTER_REPLICATION_INTERNAL_AUTOMATON
);
public static final IndexPrivilege MANAGE_VIEW = new IndexPrivilege("manage_view", MANAGE_VIEW_AUTOMATON);
public static final IndexPrivilege CREATE_VIEW = new IndexPrivilege("create_view", CREATE_VIEW_AUTOMATON);
public static final IndexPrivilege DELETE_VIEW = new IndexPrivilege("delete_view", DELETE_VIEW_AUTOMATON);
public static final IndexPrivilege READ_VIEW_METADATA = new IndexPrivilege("read_view_metadata", READ_VIEW_METADATA_AUTOMATON);

public static final IndexPrivilege READ_FAILURE_STORE = new IndexPrivilege(
"read_failure_store",
Expand Down Expand Up @@ -275,7 +284,11 @@ public final class IndexPrivilege extends Privilege {
entry("maintenance", MAINTENANCE),
entry("auto_configure", AUTO_CONFIGURE),
entry("cross_cluster_replication", CROSS_CLUSTER_REPLICATION),
entry("cross_cluster_replication_internal", CROSS_CLUSTER_REPLICATION_INTERNAL)
entry("cross_cluster_replication_internal", CROSS_CLUSTER_REPLICATION_INTERNAL),
entry("manage_view", MANAGE_VIEW),
Copy link
Contributor

Choose a reason for hiding this comment

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

There's no views feature flag behind which we could hide these right?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

There is. Refactored this to be behind a feature flag.

entry("create_view", CREATE_VIEW),
entry("delete_view", DELETE_VIEW),
entry("read_view_metadata", READ_VIEW_METADATA)
).collect(Collectors.toUnmodifiableMap(Map.Entry::getKey, Map.Entry::getValue))
)
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import org.elasticsearch.common.Strings;
import org.elasticsearch.common.util.iterable.Iterables;
import org.elasticsearch.test.ESTestCase;
import org.elasticsearch.xpack.core.esql.EsqlViewActionNames;
import org.elasticsearch.xpack.core.rollup.action.GetRollupIndexCapsAction;
import org.elasticsearch.xpack.core.security.support.Automatons;
import org.elasticsearch.xpack.core.transform.action.GetCheckpointAction;
Expand Down Expand Up @@ -71,6 +72,18 @@ public void testFindPrivilegesThatGrant() {
equalTo(List.of("monitor", "cross_cluster_replication", "manage", "all"))
);
assertThat(findPrivilegesThatGrant(RefreshAction.NAME), equalTo(List.of("maintenance", "manage", "all")));
assertThat(
findPrivilegesThatGrant(EsqlViewActionNames.ESQL_PUT_VIEW_ACTION_NAME),
equalTo(List.of("create_view", "manage_view", "manage", "all"))
);
assertThat(
findPrivilegesThatGrant(EsqlViewActionNames.ESQL_GET_VIEW_ACTION_NAME),
equalTo(List.of("read_view_metadata", "manage_view", "manage", "all"))
);
assertThat(
findPrivilegesThatGrant(EsqlViewActionNames.ESQL_DELETE_VIEW_ACTION_NAME),
equalTo(List.of("delete_view", "manage_view", "manage", "all"))
);

Predicate<IndexPrivilege> failuresOnly = p -> p.getSelectorPredicate() == IndexComponentSelectorPredicate.FAILURES;
assertThat(findPrivilegesThatGrant(TransportSearchAction.TYPE.name(), failuresOnly), equalTo(List.of("read_failure_store")));
Expand Down Expand Up @@ -114,6 +127,26 @@ public void testGet() {
assertThat(Automatons.subsetOf(IndexPrivilege.ALL.automaton, actual.automaton), is(true));
assertThat(actual.getSelectorPredicate(), equalTo(IndexComponentSelectorPredicate.ALL));
}
{
IndexPrivilege actual = IndexPrivilege.get("create_view");
assertThat(actual, equalTo(IndexPrivilege.CREATE_VIEW));
assertThat(actual.getSelectorPredicate(), equalTo(IndexComponentSelectorPredicate.DATA));
}
{
IndexPrivilege actual = IndexPrivilege.get("delete_view");
assertThat(actual, equalTo(IndexPrivilege.DELETE_VIEW));
assertThat(actual.getSelectorPredicate(), equalTo(IndexComponentSelectorPredicate.DATA));
}
{
IndexPrivilege actual = IndexPrivilege.get("read_view_metadata");
assertThat(actual, equalTo(IndexPrivilege.READ_VIEW_METADATA));
assertThat(actual.getSelectorPredicate(), equalTo(IndexComponentSelectorPredicate.DATA));
}
{
IndexPrivilege actual = IndexPrivilege.get("manage_view");
assertThat(actual, equalTo(IndexPrivilege.MANAGE_VIEW));
assertThat(actual.getSelectorPredicate(), equalTo(IndexComponentSelectorPredicate.DATA));
}
}

public void testResolveSameSelectorPrivileges() {
Expand Down Expand Up @@ -395,6 +428,43 @@ public void testCrossClusterReplicationPrivileges() {
);
}

public void testViewPrivileges() {
final IndexPrivilege createView = resolvePrivilegeAndAssertSingleton(Set.of("create_view"));
assertThat(createView.predicate.test(EsqlViewActionNames.ESQL_PUT_VIEW_ACTION_NAME), is(true));
assertThat(createView.predicate.test(EsqlViewActionNames.ESQL_PUT_VIEW_ACTION_NAME + randomAlphaOfLengthBetween(1, 8)), is(false));
assertThat(createView.getSelectorPredicate(), equalTo(IndexComponentSelectorPredicate.DATA));

final IndexPrivilege deleteView = resolvePrivilegeAndAssertSingleton(Set.of("delete_view"));
assertThat(deleteView.predicate.test(EsqlViewActionNames.ESQL_DELETE_VIEW_ACTION_NAME), is(true));
assertThat(
deleteView.predicate.test(EsqlViewActionNames.ESQL_DELETE_VIEW_ACTION_NAME + randomAlphaOfLengthBetween(1, 8)),
is(false)
);
assertThat(deleteView.getSelectorPredicate(), equalTo(IndexComponentSelectorPredicate.DATA));

final IndexPrivilege readViewMetadata = resolvePrivilegeAndAssertSingleton(Set.of("read_view_metadata"));
assertThat(readViewMetadata.predicate.test(EsqlViewActionNames.ESQL_GET_VIEW_ACTION_NAME), is(true));
assertThat(
readViewMetadata.predicate.test(EsqlViewActionNames.ESQL_GET_VIEW_ACTION_NAME + randomAlphaOfLengthBetween(1, 8)),
is(false)
);
assertThat(readViewMetadata.getSelectorPredicate(), equalTo(IndexComponentSelectorPredicate.DATA));

final IndexPrivilege manageView = resolvePrivilegeAndAssertSingleton(Set.of("manage_view"));
assertThat(manageView.predicate.test(EsqlViewActionNames.ESQL_PUT_VIEW_ACTION_NAME), is(true));
assertThat(manageView.predicate.test(EsqlViewActionNames.ESQL_GET_VIEW_ACTION_NAME), is(true));
assertThat(manageView.predicate.test(EsqlViewActionNames.ESQL_DELETE_VIEW_ACTION_NAME), is(true));
assertThat(manageView.predicate.test("indices:admin/esql/view/other"), is(true));
assertThat(manageView.getSelectorPredicate(), equalTo(IndexComponentSelectorPredicate.DATA));

assertThat(Automatons.subsetOf(createView.automaton, manageView.automaton), is(true));
assertThat(Automatons.subsetOf(deleteView.automaton, manageView.automaton), is(true));
assertThat(Automatons.subsetOf(readViewMetadata.automaton, manageView.automaton), is(true));

assertThat(Automatons.subsetOf(manageView.automaton, resolvePrivilegeAndAssertSingleton(Set.of("all")).automaton), is(true));
assertThat(Automatons.subsetOf(manageView.automaton, resolvePrivilegeAndAssertSingleton(Set.of("manage")).automaton), is(true));
}

public void testInvalidPrivilegeErrorMessage() {
final String unknownPrivilege = randomValueOtherThanMany(
i -> IndexPrivilege.values().containsKey(i),
Expand Down
Loading