diff --git a/docs/changelog/141570.yaml b/docs/changelog/141570.yaml new file mode 100644 index 0000000000000..292af0fa771cc --- /dev/null +++ b/docs/changelog/141570.yaml @@ -0,0 +1,5 @@ +area: "Authorization" +issues: [] +pr: 141570 +summary: Update View CRUD Actions to be Index Actions +type: enhancement diff --git a/server/src/main/java/org/elasticsearch/action/support/IndicesOptions.java b/server/src/main/java/org/elasticsearch/action/support/IndicesOptions.java index de6785773fb0c..a635ba58a0a7b 100644 --- a/server/src/main/java/org/elasticsearch/action/support/IndicesOptions.java +++ b/server/src/main/java/org/elasticsearch/action/support/IndicesOptions.java @@ -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 */ 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) { @@ -178,6 +180,7 @@ public static class Builder { private boolean includeHidden; private boolean resolveAliases; private boolean allowEmptyExpressions; + private boolean resolveViews; Builder() { this(DEFAULT); @@ -189,6 +192,7 @@ public static class Builder { includeHidden = options.includeHidden; resolveAliases = options.resolveAliases; allowEmptyExpressions = options.allowEmptyExpressions; + resolveViews = options.resolveViews; } /** @@ -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. */ @@ -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); } } diff --git a/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java b/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java index d50a9cd1e4d58..fbf9769700a8d 100644 --- a/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java +++ b/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java @@ -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 diff --git a/server/src/main/java/org/elasticsearch/cluster/metadata/IndexNameExpressionResolver.java b/server/src/main/java/org/elasticsearch/cluster/metadata/IndexNameExpressionResolver.java index 866d94e62e597..11718db71beeb 100644 --- a/server/src/main/java/org/elasticsearch/cluster/metadata/IndexNameExpressionResolver.java +++ b/server/src/main/java/org/elasticsearch/cluster/metadata/IndexNameExpressionResolver.java @@ -1788,7 +1788,7 @@ private static boolean shouldExpandToIndexAbstraction( String wildcardExpression, IndexAbstraction indexAbstraction ) { - if (indexAbstraction.getType() == Type.VIEW) { + if (context.getOptions().wildcardOptions().resolveViews() == false && indexAbstraction.getType() == Type.VIEW) { return false; } if (context.getOptions().ignoreAliases() && indexAbstraction.getType() == Type.ALIAS) { @@ -1843,8 +1843,9 @@ private static Set 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 (context.getOptions().wildcardOptions().resolveViews()) { + resources.add(new ResolvedExpression(indexAbstraction.getName(), selector)); + } } else { if (shouldIncludeRegularIndices(context.getOptions(), selector)) { for (int i = 0, n = indexAbstraction.getIndices().size(); i < n; i++) { diff --git a/server/src/main/java/org/elasticsearch/cluster/metadata/ProjectMetadata.java b/server/src/main/java/org/elasticsearch/cluster/metadata/ProjectMetadata.java index 26fbb03deaf2f..643beadcbdcd2 100644 --- a/server/src/main/java/org/elasticsearch/cluster/metadata/ProjectMetadata.java +++ b/server/src/main/java/org/elasticsearch/cluster/metadata/ProjectMetadata.java @@ -1851,7 +1851,7 @@ static SortedMap buildIndicesLookup( ViewMetadata viewMetadata, ImmutableOpenMap indices ) { - if (indices.isEmpty()) { + if (indices.isEmpty() && viewMetadata.views().isEmpty()) { return Collections.emptySortedMap(); } Map indicesLookup = new HashMap<>(); diff --git a/server/src/test/java/org/elasticsearch/action/admin/cluster/snapshots/create/CreateSnapshotRequestTests.java b/server/src/test/java/org/elasticsearch/action/admin/cluster/snapshots/create/CreateSnapshotRequestTests.java index 6d620acca63b7..0b76d4e0132f1 100644 --- a/server/src/test/java/org/elasticsearch/action/admin/cluster/snapshots/create/CreateSnapshotRequestTests.java +++ b/server/src/test/java/org/elasticsearch/action/admin/cluster/snapshots/create/CreateSnapshotRequestTests.java @@ -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 ) ) .gatekeeperOptions(IndicesOptions.GatekeeperOptions.builder().allowSelectors(false).includeFailureIndices(true).build()) diff --git a/server/src/test/java/org/elasticsearch/action/admin/cluster/snapshots/restore/RestoreSnapshotRequestTests.java b/server/src/test/java/org/elasticsearch/action/admin/cluster/snapshots/restore/RestoreSnapshotRequestTests.java index 3d6f5e80d5474..79dab71f0640e 100644 --- a/server/src/test/java/org/elasticsearch/action/admin/cluster/snapshots/restore/RestoreSnapshotRequestTests.java +++ b/server/src/test/java/org/elasticsearch/action/admin/cluster/snapshots/restore/RestoreSnapshotRequestTests.java @@ -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 { private RestoreSnapshotRequest randomState(RestoreSnapshotRequest instance) { @@ -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 ) ) .gatekeeperOptions(IndicesOptions.GatekeeperOptions.builder().allowSelectors(false).includeFailureIndices(true).build()) diff --git a/server/src/test/java/org/elasticsearch/action/support/IndicesOptionsTests.java b/server/src/test/java/org/elasticsearch/action/support/IndicesOptionsTests.java index 73bd251dcf300..0cfad5682d589 100644 --- a/server/src/test/java/org/elasticsearch/action/support/IndicesOptionsTests.java +++ b/server/src/test/java/org/elasticsearch/action/support/IndicesOptionsTests.java @@ -345,6 +345,7 @@ public void testToXContent() throws IOException { randomBoolean(), randomBoolean(), randomBoolean(), + randomBoolean(), randomBoolean() ); GatekeeperOptions gatekeeperOptions = new GatekeeperOptions( @@ -384,6 +385,7 @@ public void testFromXContent() throws IOException { randomBoolean(), randomBoolean(), randomBoolean(), + randomBoolean(), randomBoolean() ); ConcreteTargetOptions concreteTargetOptions = new ConcreteTargetOptions(randomBoolean()); diff --git a/test/framework/src/main/java/org/elasticsearch/test/rest/ESRestTestCase.java b/test/framework/src/main/java/org/elasticsearch/test/rest/ESRestTestCase.java index 1453386069a5a..00d1f6f13acf5 100644 --- a/test/framework/src/main/java/org/elasticsearch/test/rest/ESRestTestCase.java +++ b/test/framework/src/main/java/org/elasticsearch/test/rest/ESRestTestCase.java @@ -1343,7 +1343,9 @@ protected void wipeAllViews() throws IOException { response = cleanupClient().performRequest(request); } catch (ResponseException e) { String err = EntityUtils.toString(e.getResponse().getEntity()); - if (err.contains("no handler found for uri [_query/view]") || err.contains("Incorrect HTTP method for uri [_query/view]")) { + if (err.contains("no handler found for uri [_query/view]") + || err.contains("Incorrect HTTP method for uri [_query/view]") + || err.contains("uri [_query/view] with method [GET] exists but is not available")) { // Views are not supported, don't worry about wiping them return; } diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/esql/EsqlFeatureFlags.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/esql/EsqlFeatureFlags.java new file mode 100644 index 0000000000000..c2811ea153da9 --- /dev/null +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/esql/EsqlFeatureFlags.java @@ -0,0 +1,20 @@ +/* + * 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; + +import org.elasticsearch.common.util.FeatureFlag; + +/** + * Shared ES|QL feature flags for use across x-pack modules. + */ +public class EsqlFeatureFlags { + /** + * A feature flag to enable ESQL views REST API functionality. + */ + public static final FeatureFlag ESQL_VIEWS_FEATURE_FLAG = new FeatureFlag("esql_views"); +} diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/esql/EsqlViewActionNames.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/esql/EsqlViewActionNames.java new file mode 100644 index 0000000000000..0fbc3e3ad8e23 --- /dev/null +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/esql/EsqlViewActionNames.java @@ -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"; +} diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/privilege/IndexPrivilege.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/privilege/IndexPrivilege.java index 64a6c972cc298..ad9d6f711a07a 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/privilege/IndexPrivilege.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/privilege/IndexPrivilege.java @@ -39,6 +39,8 @@ 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.EsqlFeatureFlags; +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; @@ -198,6 +200,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); @@ -238,6 +244,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", @@ -260,29 +270,39 @@ public final class IndexPrivilege extends Privilege { .collect(Collectors.toUnmodifiableMap(Map.Entry::getKey, Map.Entry::getValue)) ), sortByAccessLevel( - Stream.of( - entry("none", NONE), - entry("all", ALL), - entry("manage", MANAGE), - entry("create_index", CREATE_INDEX), - entry("monitor", MONITOR), - entry("read", READ), - entry("index", INDEX), - entry("delete", DELETE), - entry("write", WRITE), - entry("create", CREATE), - entry("create_doc", CREATE_DOC), - entry("delete_index", DELETE_INDEX), - entry("view_index_metadata", VIEW_METADATA), - entry("read_cross_cluster", DEPRECATED_READ_CROSS_CLUSTER), - entry("manage_follow_index", MANAGE_FOLLOW_INDEX), - entry("manage_leader_index", MANAGE_LEADER_INDEX), - entry("manage_ilm", MANAGE_ILM), - entry("manage_data_stream_lifecycle", MANAGE_DATA_STREAM_LIFECYCLE), - entry("maintenance", MAINTENANCE), - entry("auto_configure", AUTO_CONFIGURE), - entry("cross_cluster_replication", CROSS_CLUSTER_REPLICATION), - entry("cross_cluster_replication_internal", CROSS_CLUSTER_REPLICATION_INTERNAL) + Stream.concat( + Stream.of( + entry("none", NONE), + entry("all", ALL), + entry("manage", MANAGE), + entry("create_index", CREATE_INDEX), + entry("monitor", MONITOR), + entry("read", READ), + entry("index", INDEX), + entry("delete", DELETE), + entry("write", WRITE), + entry("create", CREATE), + entry("create_doc", CREATE_DOC), + entry("delete_index", DELETE_INDEX), + entry("view_index_metadata", VIEW_METADATA), + entry("read_cross_cluster", DEPRECATED_READ_CROSS_CLUSTER), + entry("manage_follow_index", MANAGE_FOLLOW_INDEX), + entry("manage_leader_index", MANAGE_LEADER_INDEX), + entry("manage_ilm", MANAGE_ILM), + entry("manage_data_stream_lifecycle", MANAGE_DATA_STREAM_LIFECYCLE), + entry("maintenance", MAINTENANCE), + entry("auto_configure", AUTO_CONFIGURE), + entry("cross_cluster_replication", CROSS_CLUSTER_REPLICATION), + entry("cross_cluster_replication_internal", CROSS_CLUSTER_REPLICATION_INTERNAL) + ), + EsqlFeatureFlags.ESQL_VIEWS_FEATURE_FLAG.isEnabled() + ? Stream.of( + entry("manage_view", MANAGE_VIEW), + entry("create_view", CREATE_VIEW), + entry("delete_view", DELETE_VIEW), + entry("read_view_metadata", READ_VIEW_METADATA) + ) + : Stream.of() ).collect(Collectors.toUnmodifiableMap(Map.Entry::getKey, Map.Entry::getValue)) ) ); diff --git a/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/authz/privilege/IndexPrivilegeTests.java b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/authz/privilege/IndexPrivilegeTests.java index 0c12db1621b82..143bbc9a57bed 100644 --- a/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/authz/privilege/IndexPrivilegeTests.java +++ b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/authz/privilege/IndexPrivilegeTests.java @@ -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; @@ -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 failuresOnly = p -> p.getSelectorPredicate() == IndexComponentSelectorPredicate.FAILURES; assertThat(findPrivilegesThatGrant(TransportSearchAction.TYPE.name(), failuresOnly), equalTo(List.of("read_failure_store"))); @@ -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() { @@ -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), diff --git a/x-pack/plugin/esql/qa/security/src/javaRestTest/java/org/elasticsearch/xpack/esql/EsqlSecurityIT.java b/x-pack/plugin/esql/qa/security/src/javaRestTest/java/org/elasticsearch/xpack/esql/EsqlSecurityIT.java index 8b7331248bd2c..f083e1134015c 100644 --- a/x-pack/plugin/esql/qa/security/src/javaRestTest/java/org/elasticsearch/xpack/esql/EsqlSecurityIT.java +++ b/x-pack/plugin/esql/qa/security/src/javaRestTest/java/org/elasticsearch/xpack/esql/EsqlSecurityIT.java @@ -35,13 +35,17 @@ import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.stream.Collectors; import static org.elasticsearch.test.ListMatcher.matchesList; import static org.elasticsearch.test.MapMatcher.assertMap; import static org.elasticsearch.test.MapMatcher.matchesMap; import static org.elasticsearch.xpack.esql.qa.rest.RestEsqlTestCase.hasCapabilities; +import static org.hamcrest.Matchers.anyOf; +import static org.hamcrest.Matchers.containsInAnyOrder; import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasSize; import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.not; import static org.hamcrest.Matchers.nullValue; @@ -184,6 +188,37 @@ public void indexDocuments() throws IOException { } createMultiRoleUsers(); + createTestViews(); + } + + private void createTestViews() throws IOException { + createView("test-admin", "view-user1", "FROM index | KEEP value, org"); + createView("test-admin", "view-user2", "FROM index | KEEP value, org"); + createView("test-admin", "view", "FROM index-user1,index-user2 | KEEP value, org"); + } + + private void createView(String user, String viewName, String query) throws IOException { + Request request = new Request("PUT", "/_query/view/" + viewName); + XContentBuilder builder = JsonXContent.contentBuilder(); + builder.startObject(); + builder.field("query", query); + builder.endObject(); + request.setJsonEntity(Strings.toString(builder)); + setUser(request, user); + assertOK(client().performRequest(request)); + } + + private Response getView(String user, String... viewNames) throws IOException { + String path = viewNames.length != 0 ? "/_query/view/" + String.join(",", viewNames) : "/_query/view"; + Request request = new Request("GET", path); + setUser(request, user); + return client().performRequest(request); + } + + private Response deleteView(String user, String viewName) throws IOException { + Request request = new Request("DELETE", "/_query/view/" + viewName); + setUser(request, user); + return client().performRequest(request); } private void createMultiRoleUsers() throws IOException { @@ -995,6 +1030,219 @@ public void testGetQueryForbidden() throws Exception { assertThat(resp.getMessage(), containsString("this action is granted by the cluster privileges [monitor_esql,monitor,manage,all]")); } + @SuppressWarnings("unchecked") + public void testGetViewAllowed() throws Exception { + { + var resp = getView("user1", randomFrom(new String[] { "view-user1", "view" }, new String[] { "*" }, new String[] { "_all" })); + assertOK(resp); + var respMap = entityAsMap(resp); + var views = (List>) respMap.get("views"); + assertThat(views.size(), equalTo(2)); + assertThat(views.stream().map(entry -> entry.get("name")).toList(), containsInAnyOrder("view", "view-user1")); + } + { + var resp = getView("user2", randomFrom("view-user2", "*", "_all")); + assertOK(resp); + var respMap = entityAsMap(resp); + var views = (List>) respMap.get("views"); + assertThat(views.size(), equalTo(1)); + assertThat(views.getFirst().get("name"), equalTo("view-user2")); + } + { + var resp = getView("test-admin"); + assertOK(resp); + var respMap = entityAsMap(resp); + var views = (List>) respMap.get("views"); + assertThat(views.size(), equalTo(3)); + } + } + + public void testGetViewForbidden() { + { + var resp = expectThrows(ResponseException.class, () -> getView("user_without_monitor_privileges", "view-user1")); + assertThat(resp.getResponse().getStatusLine().getStatusCode(), equalTo(403)); + assertThat( + resp.getMessage(), + anyOf(containsString("this action is granted by the index privileges [read_view_metadata,manage_view,manage,all]")) + ); + } + { + var resp = expectThrows(ResponseException.class, () -> getView("user2", "view-user1")); + assertThat(resp.getResponse().getStatusLine().getStatusCode(), equalTo(403)); + assertThat( + resp.getMessage(), + anyOf(containsString("this action is granted by the index privileges [read_view_metadata,manage_view,manage,all]")) + ); + } + } + + @SuppressWarnings("unchecked") + public void testGetViewWildcardNoIndices() throws Exception { + var resp = getView("user1", "view-user2*"); + assertOK(resp); + var respMap = entityAsMap(resp); + var views = (List>) respMap.get("views"); + assertThat(views, hasSize(0)); + } + + @SuppressWarnings("unchecked") + public void testGetViewWildcardAndConcrete() throws Exception { + var resp = getView("user1", "view-user1", "vie*"); + assertOK(resp); + var respMap = entityAsMap(resp); + var views = (List>) respMap.get("views"); + var viewNames = views.stream().map(entry -> entry.get("name")).collect(Collectors.toSet()); + assertThat(viewNames, hasSize(2)); + assertThat(viewNames, containsInAnyOrder("view", "view-user1")); + } + + public void testCreateViewAllowed() throws Exception { + createView("user1", "other-view-user1", "FROM index | KEEP value, org"); + createView("user2", "other-view-user2", "FROM index | KEEP value, org"); + } + + public void testCreateViewForbidden() { + { + var resp = expectThrows(ResponseException.class, () -> createView("user2", "other-view-user1", "FROM index | KEEP value, org")); + assertThat(resp.getResponse().getStatusLine().getStatusCode(), equalTo(403)); + assertThat( + resp.getMessage(), + anyOf(containsString("this action is granted by the index privileges [create_view,manage_view,manage,all]")) + ); + } + { + var resp = expectThrows(ResponseException.class, () -> createView("user1", "other-view-user2", "FROM index | KEEP value, org")); + assertThat(resp.getResponse().getStatusLine().getStatusCode(), equalTo(403)); + assertThat( + resp.getMessage(), + anyOf(containsString("this action is granted by the index privileges [create_view,manage_view,manage,all]")) + ); + } + { + var resp = expectThrows(ResponseException.class, () -> createView("user3", "any-name", "FROM index | KEEP value, org")); + assertThat(resp.getResponse().getStatusLine().getStatusCode(), equalTo(403)); + assertThat( + resp.getMessage(), + anyOf(containsString("this action is granted by the index privileges [create_view,manage_view,manage,all]")) + ); + } + } + + @SuppressWarnings("unchecked") + public void testUpdateViewAllowed() throws Exception { + { + createView("user1", "view-user1", "FROM index | KEEP value | STATS sum=sum(value)"); + var resp = getView("user1", "view-user1"); + assertOK(resp); + var respMap = entityAsMap(resp); + var views = (List>) respMap.get("views"); + assertThat(views.size(), equalTo(1)); + assertThat(views.getFirst().get("query"), equalTo("FROM index | KEEP value | STATS sum=sum(value)")); + } + { + createView("user2", "view-user2", "FROM index | STATS count=COUNT(*)"); + var resp = getView("user2", "view-user2"); + assertOK(resp); + var respMap = entityAsMap(resp); + var views = (List>) respMap.get("views"); + assertThat(views.size(), equalTo(1)); + assertThat(views.getFirst().get("query"), equalTo("FROM index | STATS count=COUNT(*)")); + } + { + createView("test-admin", "view-user1", "FROM index | LIMIT 10"); + var resp = getView("test-admin", "view-user1"); + assertOK(resp); + var respMap = entityAsMap(resp); + var views = (List>) respMap.get("views"); + assertThat(views.size(), equalTo(1)); + assertThat(views.getFirst().get("query"), equalTo("FROM index | LIMIT 10")); + } + } + + public void testUpdateViewForbidden() { + { + var resp = expectThrows(ResponseException.class, () -> createView("user2", "view-user1", "FROM index | KEEP value, org")); + assertThat(resp.getResponse().getStatusLine().getStatusCode(), equalTo(403)); + assertThat( + resp.getMessage(), + anyOf(containsString("this action is granted by the index privileges [create_view,manage_view,manage,all]")) + ); + } + { + var resp = expectThrows(ResponseException.class, () -> createView("user1", "view-user2", "FROM index | KEEP value, org")); + assertThat(resp.getResponse().getStatusLine().getStatusCode(), equalTo(403)); + assertThat( + resp.getMessage(), + anyOf(containsString("this action is granted by the index privileges [create_view,manage_view,manage,all]")) + ); + } + { + var resp = expectThrows(ResponseException.class, () -> createView("user3", "view-user1", "FROM index | KEEP value, org")); + assertThat(resp.getResponse().getStatusLine().getStatusCode(), equalTo(403)); + assertThat( + resp.getMessage(), + anyOf(containsString("this action is granted by the index privileges [create_view,manage_view,manage,all]")) + ); + } + } + + public void testDeleteViewAllowed() throws Exception { + createView("user1", "other-view-user1", "FROM index | KEEP value, org"); + createView("user2", "other-view-user2", "FROM index | KEEP value, org"); + createView("test-admin", "other-view-admin", "FROM index | KEEP value, org"); + + { + var resp = deleteView("user1", "other-view-user1"); + assertOK(resp); + } + { + var resp = deleteView("user2", "other-view-user2"); + assertOK(resp); + } + { + var resp = deleteView("test-admin", "other-view-admin"); + assertOK(resp); + } + } + + public void testDeleteViewForbidden() { + { + var resp = expectThrows(ResponseException.class, () -> deleteView("user2", "view-user1")); + assertThat(resp.getResponse().getStatusLine().getStatusCode(), equalTo(403)); + assertThat( + resp.getMessage(), + anyOf(containsString("this action is granted by the index privileges [delete_view,manage_view,manage,all]")) + ); + } + { + var resp = expectThrows(ResponseException.class, () -> deleteView("user1", "view-user2")); + assertThat(resp.getResponse().getStatusLine().getStatusCode(), equalTo(403)); + assertThat( + resp.getMessage(), + anyOf(containsString("this action is granted by the index privileges [delete_view,manage_view,manage,all]")) + ); + } + { + var resp = expectThrows(ResponseException.class, () -> deleteView("user3", "view-user1")); + assertThat(resp.getResponse().getStatusLine().getStatusCode(), equalTo(403)); + assertThat( + resp.getMessage(), + anyOf(containsString("this action is granted by the index privileges [delete_view,manage_view,manage,all]")) + ); + } + } + + @SuppressWarnings("unchecked") + public void testGetViewWildcard() throws Exception { + Response resp = getView("user1", randomFrom("vie*", "*", "*iew*")); + assertOK(resp); + Map respMap = entityAsMap(resp); + List> views = (List>) respMap.get("views"); + var viewNames = views.stream().map(view -> view.get("name")).collect(Collectors.toSet()); + assertThat(viewNames, hasSize(2)); + assertThat(viewNames, containsInAnyOrder("view-user1", "view")); + } + private static final Request GET_QUERY_REQUEST = new Request( "GET", "_query/queries/FmJKWHpFRi1OU0l5SU1YcnpuWWhoUWcZWDFuYUJBeW1TY0dKM3otWUs2bDJudzo1Mg==" diff --git a/x-pack/plugin/esql/qa/security/src/javaRestTest/resources/roles.yml b/x-pack/plugin/esql/qa/security/src/javaRestTest/resources/roles.yml index f2a3d93cdc559..fcc1f85435c51 100644 --- a/x-pack/plugin/esql/qa/security/src/javaRestTest/resources/roles.yml +++ b/x-pack/plugin/esql/qa/security/src/javaRestTest/resources/roles.yml @@ -15,22 +15,26 @@ user1: - cluster:monitor/main - manage_enrich indices: - - names: ['index-user1', 'index', "test-enrich" ] + - names: ['index-user1', 'view-user1', "view", 'other-view-user1', 'index', "test-enrich" ] privileges: - read - write - create_index - indices:admin/refresh + - manage_view user2: cluster: [] indices: - - names: [ 'index-user2', 'index' ] + - names: [ 'index-user2', 'view-user2', 'other-view-user2', 'index' ] privileges: - read - write - create_index - indices:admin/refresh + - create_view + - read_view_metadata + - delete_view metadata1_read2: cluster: [] diff --git a/x-pack/plugin/esql/qa/server/src/main/java/org/elasticsearch/xpack/esql/qa/rest/EsqlSpecTestCase.java b/x-pack/plugin/esql/qa/server/src/main/java/org/elasticsearch/xpack/esql/qa/rest/EsqlSpecTestCase.java index 72a606e7766c3..1aa69c1403c4c 100644 --- a/x-pack/plugin/esql/qa/server/src/main/java/org/elasticsearch/xpack/esql/qa/rest/EsqlSpecTestCase.java +++ b/x-pack/plugin/esql/qa/server/src/main/java/org/elasticsearch/xpack/esql/qa/rest/EsqlSpecTestCase.java @@ -661,7 +661,7 @@ protected boolean supportsTook() throws IOException { protected boolean supportsViews() { if (supportsViews == null) { - supportsViews = hasCapabilities(adminClient(), List.of("views_with_no_branching")); + supportsViews = hasCapabilities(adminClient(), List.of("views_with_no_branching", "views_crud_as_index_actions")); } return supportsViews; } diff --git a/x-pack/plugin/esql/qa/testFixtures/src/main/java/org/elasticsearch/xpack/esql/CsvTestsDataLoader.java b/x-pack/plugin/esql/qa/testFixtures/src/main/java/org/elasticsearch/xpack/esql/CsvTestsDataLoader.java index d479edd7c80e2..a6dc734f4e448 100644 --- a/x-pack/plugin/esql/qa/testFixtures/src/main/java/org/elasticsearch/xpack/esql/CsvTestsDataLoader.java +++ b/x-pack/plugin/esql/qa/testFixtures/src/main/java/org/elasticsearch/xpack/esql/CsvTestsDataLoader.java @@ -935,7 +935,7 @@ private static boolean clusterHasViewSupport(RestClient client, Logger logger) t logger.info("View listing error: {}", e.getMessage()); int code = e.getResponse().getStatusLine().getStatusCode(); // Different versions of Elasticsearch return different codes when views are not supported - if (code == 400 || code == 500 || code == 405) { + if (code == 410 || code == 400 || code == 500 || code == 405) { return false; } throw e; diff --git a/x-pack/plugin/esql/qa/testFixtures/src/main/resources/views.csv-spec b/x-pack/plugin/esql/qa/testFixtures/src/main/resources/views.csv-spec index 22a160b641663..4c632d3033099 100644 --- a/x-pack/plugin/esql/qa/testFixtures/src/main/resources/views.csv-spec +++ b/x-pack/plugin/esql/qa/testFixtures/src/main/resources/views.csv-spec @@ -1,5 +1,6 @@ countryAddresses required_capability: views_with_no_branching +required_capability: views_crud_as_index_actions FROM country_addresses ; @@ -12,6 +13,8 @@ count:long | country:keyword ; airports +required_capability: views_crud_as_index_actions + FROM airports | WHERE country IS NOT NULL | STATS count=COUNT() BY country @@ -40,6 +43,7 @@ count:long | country:keyword countryAirports required_capability: views_with_no_branching +required_capability: views_crud_as_index_actions FROM country_airports ; @@ -65,6 +69,7 @@ count:long | country:keyword countryAirportsFiltered required_capability: views_with_no_branching +required_capability: views_crud_as_index_actions FROM country_airports | WHERE count > 15 @@ -85,6 +90,7 @@ count:long | country:keyword countryAirportsAddresses required_capability: views_with_branching +required_capability: views_crud_as_index_actions FROM country_addresses, country_airports | STATS count=SUM(count) BY country @@ -109,6 +115,7 @@ count:long | country:keyword countryAirportsAddressesWildcard required_capability: views_with_branching +required_capability: views_crud_as_index_actions FROM country_a* | STATS count=SUM(count) BY country @@ -133,6 +140,7 @@ count:long | country:keyword countryAirportsAddressesUS required_capability: views_with_branching +required_capability: views_crud_as_index_actions FROM country_addresses, country_airports | WHERE country == "United States" @@ -146,6 +154,7 @@ count:long | country:keyword languages required_capability: views_with_no_branching +required_capability: views_crud_as_index_actions FROM languages_lookup_non_unique_key | MV_EXPAND country @@ -168,6 +177,7 @@ count:long | country:keyword countryLanguages required_capability: views_with_no_branching +required_capability: views_crud_as_index_actions FROM country_languages ; @@ -185,6 +195,7 @@ count:long | country:keyword countryAirportsAddressesLanguages required_capability: views_with_branching +required_capability: views_crud_as_index_actions FROM country_addresses, country_airports, country_languages | STATS count=SUM(count) BY country @@ -210,6 +221,7 @@ count:long | country:keyword countryAirportsAddressesLanguagesWildcard required_capability: views_with_branching +required_capability: views_crud_as_index_actions FROM country_* | STATS count=SUM(count) BY country @@ -236,6 +248,7 @@ count:long | country:keyword airportsLookupJoin required_capability: join_lookup_v12 required_capability: lookup_join_on_boolean_expression +required_capability: views_crud_as_index_actions FROM airports | RENAME abbrev AS code @@ -259,6 +272,7 @@ airportsLookupJoinView required_capability: join_lookup_v12 required_capability: lookup_join_on_boolean_expression required_capability: views_with_no_branching +required_capability: views_crud_as_index_actions FROM airports_mp_filtered ; @@ -277,6 +291,7 @@ airportsLookupJoinViewAirports required_capability: join_lookup_v12 required_capability: lookup_join_on_boolean_expression required_capability: views_with_branching +required_capability: views_crud_as_index_actions FROM airports_mp_filtered, airports METADATA _index | WHERE abbrev == "LUH" @@ -292,6 +307,7 @@ airportsLookupJoinViewAirportsStats required_capability: join_lookup_v12 required_capability: lookup_join_on_boolean_expression required_capability: views_with_branching +required_capability: views_crud_as_index_actions FROM airports_mp_filtered, airports | STATS duplications=COUNT() BY abbrev @@ -309,6 +325,7 @@ airportsLookupJoinViewAirportsStatsValues required_capability: join_lookup_v12 required_capability: lookup_join_on_boolean_expression required_capability: views_with_branching +required_capability: views_crud_as_index_actions FROM airports_mp_filtered, airports METADATA _index | EVAL _index = CASE(_index IS NULL, "view", _index) @@ -337,6 +354,7 @@ duplications:long | abbrev:keyword | indexes:keyword queryThatWillNotBeDeterministicWithViews required_capability: views_with_branching +required_capability: views_crud_as_index_actions FROM * | EVAL country=TO_STRING(country) @@ -356,6 +374,8 @@ foo:long | count:long | country:keyword // Testing edge cases with multi-value fields in views multiValueWarningsWithNoViews +required_capability: views_crud_as_index_actions + FROM employees | WHERE is_rehired == true | STATS rehired_count = COUNT() @@ -369,6 +389,8 @@ rehired_count:long multiValueWarningsWithView required_capability: views_with_no_branching +required_capability: views_crud_as_index_actions + FROM employees_rehired | STATS rehired_count = COUNT() ; @@ -381,6 +403,8 @@ rehired_count:long multiValueWarningsWithBranchedView required_capability: views_with_branching +required_capability: views_crud_as_index_actions + FROM employees_*rehired | STATS rehired_count = COUNT() by is_rehired | SORT is_rehired DESC diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/action/EsqlCapabilities.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/action/EsqlCapabilities.java index 77ea7fe3ba9e6..6e0a9f396a2cd 100644 --- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/action/EsqlCapabilities.java +++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/action/EsqlCapabilities.java @@ -12,6 +12,7 @@ import org.elasticsearch.compute.lucene.read.ValuesSourceReaderOperator; import org.elasticsearch.features.NodeFeature; import org.elasticsearch.rest.action.admin.cluster.RestNodesCapabilitiesAction; +import org.elasticsearch.xpack.core.esql.EsqlFeatureFlags; import org.elasticsearch.xpack.esql.optimizer.rules.logical.ReplaceStatsFilteredOrNullAggWithEval; import org.elasticsearch.xpack.esql.plugin.EsqlFeatures; @@ -1178,13 +1179,16 @@ public enum Cap { /** * Support for views in cluster state (and REST API). */ - VIEWS_IN_CLUSTER_STATE(EsqlFeatures.ESQL_VIEWS_FEATURE_FLAG.isEnabled()), + VIEWS_IN_CLUSTER_STATE(EsqlFeatureFlags.ESQL_VIEWS_FEATURE_FLAG.isEnabled()), /** * Basic Views with no branching (do not need subqueries or FORK). */ VIEWS_WITH_NO_BRANCHING(VIEWS_IN_CLUSTER_STATE.isEnabled()), - + /** + * Views crud actions as index actions + */ + VIEWS_CRUD_AS_INDEX_ACTIONS(VIEWS_WITH_NO_BRANCHING.isEnabled()), /** * Views with branching (requires subqueries/FORK). */ diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plugin/EsqlFeatures.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plugin/EsqlFeatures.java index a1449eca201dc..89b4231a999d4 100644 --- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plugin/EsqlFeatures.java +++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plugin/EsqlFeatures.java @@ -8,7 +8,6 @@ package org.elasticsearch.xpack.esql.plugin; import org.elasticsearch.Build; -import org.elasticsearch.common.util.FeatureFlag; import org.elasticsearch.common.util.set.Sets; import org.elasticsearch.features.FeatureSpecification; import org.elasticsearch.features.NodeFeature; @@ -35,11 +34,6 @@ public class EsqlFeatures implements FeatureSpecification { */ public static final NodeFeature METRICS_SYNTAX = new NodeFeature("esql.metrics_syntax"); - /** - * A feature flag to enable ESQL views REST API functionality. - */ - public static final FeatureFlag ESQL_VIEWS_FEATURE_FLAG = new FeatureFlag("esql_views"); - private Set snapshotBuildFeatures() { assert Build.current().isSnapshot() : Build.current(); return Set.of(METRICS_SYNTAX); diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plugin/EsqlPlugin.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plugin/EsqlPlugin.java index e934f483ff9ca..e47d783ed753c 100644 --- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plugin/EsqlPlugin.java +++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plugin/EsqlPlugin.java @@ -107,7 +107,7 @@ import java.util.function.Predicate; import java.util.function.Supplier; -import static org.elasticsearch.xpack.esql.plugin.EsqlFeatures.ESQL_VIEWS_FEATURE_FLAG; +import static org.elasticsearch.xpack.core.esql.EsqlFeatureFlags.ESQL_VIEWS_FEATURE_FLAG; public class EsqlPlugin extends Plugin implements ActionPlugin, ExtensiblePlugin, SearchPlugin { diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/view/DeleteViewAction.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/view/DeleteViewAction.java index 51059dbe03b5b..5e84b3abc2a48 100644 --- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/view/DeleteViewAction.java +++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/view/DeleteViewAction.java @@ -8,12 +8,15 @@ import org.elasticsearch.action.ActionRequestValidationException; import org.elasticsearch.action.ActionType; +import org.elasticsearch.action.IndicesRequest; +import org.elasticsearch.action.support.IndicesOptions; import org.elasticsearch.action.support.master.AcknowledgedRequest; import org.elasticsearch.action.support.master.AcknowledgedResponse; import org.elasticsearch.common.Strings; import org.elasticsearch.common.io.stream.StreamInput; import org.elasticsearch.common.io.stream.StreamOutput; import org.elasticsearch.core.TimeValue; +import org.elasticsearch.xpack.core.esql.EsqlViewActionNames; import java.io.IOException; import java.util.Objects; @@ -23,13 +26,19 @@ public class DeleteViewAction extends ActionType { public static final DeleteViewAction INSTANCE = new DeleteViewAction(); - public static final String NAME = "cluster:admin/xpack/esql/view/delete"; + public static final String NAME = EsqlViewActionNames.ESQL_DELETE_VIEW_ACTION_NAME; + + public static final IndicesOptions DEFAULT_INDICES_OPTIONS = IndicesOptions.builder() + .concreteTargetOptions(IndicesOptions.ConcreteTargetOptions.ERROR_WHEN_UNAVAILABLE_TARGETS) + .wildcardOptions(IndicesOptions.WildcardOptions.builder().resolveViews(true).build()) + .build(); private DeleteViewAction() { super(NAME); } - public static class Request extends AcknowledgedRequest { + public static class Request extends AcknowledgedRequest implements IndicesRequest { + // TODO this currently doesn't support multi-target syntax, but should probably if action.destructive_requires_name=false private final String name; public Request(TimeValue masterNodeTimeout, TimeValue ackTimeout, String name) { @@ -73,5 +82,15 @@ public boolean equals(Object o) { public int hashCode() { return Objects.hash(name); } + + @Override + public String[] indices() { + return new String[] { name }; + } + + @Override + public IndicesOptions indicesOptions() { + return DEFAULT_INDICES_OPTIONS; + } } } diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/view/GetViewAction.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/view/GetViewAction.java index 5abe2150c3a20..2b5a89ed06d04 100644 --- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/view/GetViewAction.java +++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/view/GetViewAction.java @@ -8,6 +8,8 @@ import org.elasticsearch.action.ActionResponse; import org.elasticsearch.action.ActionType; +import org.elasticsearch.action.IndicesRequest; +import org.elasticsearch.action.support.IndicesOptions; import org.elasticsearch.action.support.TransportAction; import org.elasticsearch.action.support.local.LocalClusterStateRequest; import org.elasticsearch.cluster.metadata.View; @@ -19,29 +21,35 @@ import org.elasticsearch.tasks.TaskId; import org.elasticsearch.xcontent.ToXContentObject; import org.elasticsearch.xcontent.XContentBuilder; +import org.elasticsearch.xpack.core.esql.EsqlViewActionNames; import java.io.IOException; +import java.util.Arrays; import java.util.Collection; -import java.util.Collections; -import java.util.List; import java.util.Map; import java.util.Objects; +import static org.elasticsearch.action.support.IndicesOptions.ConcreteTargetOptions.ERROR_WHEN_UNAVAILABLE_TARGETS; + public class GetViewAction extends ActionType { public static final GetViewAction INSTANCE = new GetViewAction(); - public static final String NAME = "cluster:admin/xpack/esql/view/get"; + public static final String NAME = EsqlViewActionNames.ESQL_GET_VIEW_ACTION_NAME; + + private static final IndicesOptions VIEW_INDICES_OPTIONS = IndicesOptions.builder() + .wildcardOptions(IndicesOptions.WildcardOptions.builder().resolveViews(true)) + .concreteTargetOptions(ERROR_WHEN_UNAVAILABLE_TARGETS) + .build(); private GetViewAction() { super(NAME); } - public static class Request extends LocalClusterStateRequest { - private final List names; + public static class Request extends LocalClusterStateRequest implements IndicesRequest.Replaceable { + private String[] indices; - public Request(TimeValue masterNodeTimeout, String... names) { + public Request(TimeValue masterNodeTimeout) { super(masterNodeTimeout); - this.names = List.of(names); } @Override @@ -49,8 +57,20 @@ public Task createTask(long id, String type, String action, TaskId parentTaskId, return new CancellableTask(id, type, action, getDescription(), parentTaskId, headers); } - public List names() { - return names; + @Override + public String[] indices() { + return indices; + } + + @Override + public IndicesOptions indicesOptions() { + return VIEW_INDICES_OPTIONS; + } + + @Override + public IndicesRequest indices(String... indices) { + this.indices = indices; + return this; } @Override @@ -58,12 +78,12 @@ public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; Request request = (Request) o; - return Objects.equals(names, request.names); + return Arrays.equals(this.indices, request.indices); } @Override public int hashCode() { - return Objects.hash(names); + return Arrays.hashCode(indices); } } @@ -73,7 +93,7 @@ public static class Response extends ActionResponse implements ToXContentObject public Response(Collection views) { Objects.requireNonNull(views, "views cannot be null"); - this.views = Collections.unmodifiableCollection(views); + this.views = views; } public Collection getViews() { @@ -101,10 +121,10 @@ public boolean equals(Object o) { if (this == o) { return true; } - if (o == null || getClass() != o.getClass()) { - return false; + if (o instanceof GetViewAction.Response response) { + return this.views.equals(response.views); } - return views.equals(((Response) o).views); + return false; } @Override @@ -114,7 +134,7 @@ public int hashCode() { @Override public String toString() { - return "GetViewAction.Response" + views.stream().map(View::name).toList(); + return "GetViewAction.Response" + Arrays.toString(views.stream().map(View::name).toArray(String[]::new)); } } } diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/view/PutViewAction.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/view/PutViewAction.java index 4f5605984ab7b..e501c482bdd2d 100644 --- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/view/PutViewAction.java +++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/view/PutViewAction.java @@ -8,6 +8,8 @@ import org.elasticsearch.action.ActionRequestValidationException; import org.elasticsearch.action.ActionType; +import org.elasticsearch.action.IndicesRequest; +import org.elasticsearch.action.support.IndicesOptions; import org.elasticsearch.action.support.master.AcknowledgedRequest; import org.elasticsearch.action.support.master.AcknowledgedResponse; import org.elasticsearch.cluster.metadata.MetadataCreateIndexService; @@ -16,6 +18,7 @@ import org.elasticsearch.common.io.stream.StreamInput; import org.elasticsearch.common.io.stream.StreamOutput; import org.elasticsearch.core.TimeValue; +import org.elasticsearch.xpack.core.esql.EsqlViewActionNames; import java.io.IOException; import java.util.Locale; @@ -26,13 +29,18 @@ public class PutViewAction extends ActionType { public static final PutViewAction INSTANCE = new PutViewAction(); - public static final String NAME = "cluster:admin/xpack/esql/view/put"; + public static final String NAME = EsqlViewActionNames.ESQL_PUT_VIEW_ACTION_NAME; + + public static final IndicesOptions DEFAULT_INDICES_OPTIONS = IndicesOptions.builder() + .concreteTargetOptions(IndicesOptions.ConcreteTargetOptions.ERROR_WHEN_UNAVAILABLE_TARGETS) + .wildcardOptions(IndicesOptions.WildcardOptions.builder().resolveViews(true).build()) + .build(); private PutViewAction() { super(NAME); } - public static class Request extends AcknowledgedRequest { + public static class Request extends AcknowledgedRequest implements IndicesRequest { private final View view; public Request(TimeValue masterNodeTimeout, TimeValue ackTimeout, View view) { @@ -95,5 +103,15 @@ public boolean equals(Object o) { public int hashCode() { return Objects.hash(view); } + + @Override + public String[] indices() { + return new String[] { view.getName() }; + } + + @Override + public IndicesOptions indicesOptions() { + return DEFAULT_INDICES_OPTIONS; + } } } diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/view/RestDeleteViewAction.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/view/RestDeleteViewAction.java index 5d8618905692f..713b4cbe0cada 100644 --- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/view/RestDeleteViewAction.java +++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/view/RestDeleteViewAction.java @@ -11,15 +11,12 @@ import org.elasticsearch.rest.BaseRestHandler; import org.elasticsearch.rest.RestRequest; import org.elasticsearch.rest.RestUtils; -import org.elasticsearch.rest.Scope; -import org.elasticsearch.rest.ServerlessScope; import org.elasticsearch.rest.action.RestToXContentListener; import java.util.List; import static org.elasticsearch.rest.RestRequest.Method.DELETE; -@ServerlessScope(Scope.PUBLIC) public class RestDeleteViewAction extends BaseRestHandler { @Override public List routes() { diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/view/RestGetViewAction.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/view/RestGetViewAction.java index c902199e1f864..5e56d35312312 100644 --- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/view/RestGetViewAction.java +++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/view/RestGetViewAction.java @@ -8,12 +8,11 @@ package org.elasticsearch.xpack.esql.view; import org.elasticsearch.client.internal.node.NodeClient; +import org.elasticsearch.cluster.metadata.Metadata; import org.elasticsearch.common.Strings; import org.elasticsearch.rest.BaseRestHandler; import org.elasticsearch.rest.RestRequest; import org.elasticsearch.rest.RestUtils; -import org.elasticsearch.rest.Scope; -import org.elasticsearch.rest.ServerlessScope; import org.elasticsearch.rest.action.RestCancellableNodeClient; import org.elasticsearch.rest.action.RestToXContentListener; @@ -23,7 +22,6 @@ import static org.elasticsearch.rest.RestRequest.Method.GET; -@ServerlessScope(Scope.PUBLIC) public class RestGetViewAction extends BaseRestHandler { @Override public List routes() { @@ -37,10 +35,10 @@ public String getName() { @Override protected RestChannelConsumer prepareRequest(final RestRequest request, final NodeClient client) throws IOException { - GetViewAction.Request req = new GetViewAction.Request( - RestUtils.getMasterNodeTimeout(request), - Strings.splitStringByCommaToArray(request.param("name")) - ); + GetViewAction.Request req = new GetViewAction.Request(RestUtils.getMasterNodeTimeout(request)); + var requestedViews = Strings.splitStringByCommaToArray(request.param("name")); + req.indices(isGetAllViews(requestedViews) ? new String[] { "*" } : requestedViews); + return channel -> new RestCancellableNodeClient(client, request.getHttpChannel()).execute( GetViewAction.INSTANCE, req, @@ -48,6 +46,10 @@ protected RestChannelConsumer prepareRequest(final RestRequest request, final No ); } + private boolean isGetAllViews(String[] requestedViews) { + return requestedViews.length == 0 || requestedViews[0].equals(Metadata.ALL); + } + @Override public Set supportedCapabilities() { return Set.of("view_index_abstraction"); diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/view/RestPutViewAction.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/view/RestPutViewAction.java index c9ac320dea96d..4c5da6cef4e0d 100644 --- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/view/RestPutViewAction.java +++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/view/RestPutViewAction.java @@ -12,8 +12,6 @@ import org.elasticsearch.rest.BaseRestHandler; import org.elasticsearch.rest.RestRequest; import org.elasticsearch.rest.RestUtils; -import org.elasticsearch.rest.Scope; -import org.elasticsearch.rest.ServerlessScope; import org.elasticsearch.rest.action.RestToXContentListener; import org.elasticsearch.xcontent.XContentParser; @@ -23,7 +21,6 @@ import static org.elasticsearch.rest.RestRequest.Method.PUT; -@ServerlessScope(Scope.PUBLIC) public class RestPutViewAction extends BaseRestHandler { private static final String VIEW_INDEX_ABSTRACTION = "view_index_abstraction"; diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/view/TransportGetViewAction.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/view/TransportGetViewAction.java index 974bc5bebde78..701f74e4573cd 100644 --- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/view/TransportGetViewAction.java +++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/view/TransportGetViewAction.java @@ -19,13 +19,17 @@ import org.elasticsearch.cluster.metadata.View; import org.elasticsearch.cluster.project.ProjectResolver; import org.elasticsearch.cluster.service.ClusterService; +import org.elasticsearch.common.regex.Regex; import org.elasticsearch.common.util.concurrent.EsExecutors; import org.elasticsearch.injection.guice.Inject; import org.elasticsearch.tasks.Task; import org.elasticsearch.transport.TransportService; +import org.elasticsearch.xpack.core.security.authz.IndicesAndAliasesResolverField; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collection; +import java.util.LinkedHashSet; import java.util.List; public class TransportGetViewAction extends TransportLocalProjectMetadataAction { @@ -59,16 +63,20 @@ protected void localClusterStateOperation( ActionListener listener ) { ProjectId projectId = project.projectId(); - Collection views = new ArrayList<>(); + Collection views = new LinkedHashSet<>(); List missing = new ArrayList<>(); - List names = request.names(); - if (names.isEmpty()) { + String[] names = request.indices(); + // TODO currently doesn't support wildcards when security is off + if (names == null || names.length == 0 || (names.length == 1 && Regex.isMatchAllPattern(names[0]))) { views = viewService.getMetadata(projectId).views().values(); - } else { + } else if (Arrays.equals(names, IndicesAndAliasesResolverField.NO_INDICES_OR_ALIASES_ARRAY) == false) { for (String name : names) { View view = viewService.get(projectId, name); if (view == null) { - missing.add(name); + // TODO currently doesn't throw an error when a concrete existing index is used as a view name in the API, returns empty + if (project.metadata().getIndicesLookup().containsKey(name) == false) { + missing.add(name); + } } else { views.add(view); } diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/view/ViewResolver.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/view/ViewResolver.java index b4f77f6753f79..fdcbea9408fe9 100644 --- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/view/ViewResolver.java +++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/view/ViewResolver.java @@ -17,6 +17,7 @@ import org.elasticsearch.index.IndexMode; import org.elasticsearch.logging.LogManager; import org.elasticsearch.logging.Logger; +import org.elasticsearch.xpack.core.esql.EsqlFeatureFlags; import org.elasticsearch.xpack.esql.VerificationException; import org.elasticsearch.xpack.esql.plan.IndexPattern; import org.elasticsearch.xpack.esql.plan.logical.Fork; @@ -24,7 +25,6 @@ import org.elasticsearch.xpack.esql.plan.logical.Subquery; import org.elasticsearch.xpack.esql.plan.logical.UnionAll; import org.elasticsearch.xpack.esql.plan.logical.UnresolvedRelation; -import org.elasticsearch.xpack.esql.plugin.EsqlFeatures; import java.util.ArrayList; import java.util.Collection; @@ -76,7 +76,7 @@ protected Map getIndicesLookup() { } protected boolean viewsFeatureEnabled() { - return EsqlFeatures.ESQL_VIEWS_FEATURE_FLAG.isEnabled(); + return EsqlFeatureFlags.ESQL_VIEWS_FEATURE_FLAG.isEnabled(); } /** diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/view/ViewService.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/view/ViewService.java index a2e750b97b9f9..a5931398e490f 100644 --- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/view/ViewService.java +++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/view/ViewService.java @@ -28,11 +28,11 @@ import org.elasticsearch.core.Nullable; import org.elasticsearch.logging.LogManager; import org.elasticsearch.logging.Logger; +import org.elasticsearch.xpack.core.esql.EsqlFeatureFlags; import org.elasticsearch.xpack.esql.expression.function.EsqlFunctionRegistry; import org.elasticsearch.xpack.esql.inference.InferenceSettings; import org.elasticsearch.xpack.esql.parser.EsqlParser; import org.elasticsearch.xpack.esql.parser.QueryParams; -import org.elasticsearch.xpack.esql.plugin.EsqlFeatures; import org.elasticsearch.xpack.esql.telemetry.PlanTelemetry; import java.util.HashMap; @@ -140,6 +140,7 @@ public ClusterState execute(ClusterState currentState) { * Removes a view from the cluster state. */ public void deleteView(ProjectId projectId, DeleteViewAction.Request request, ActionListener listener) { + // TODO this should support wildcard deletion if action.destructive_requires_name = false if (viewsFeatureEnabled() == false) { listener.onFailure(new IllegalArgumentException("ESQL views are not enabled")); return; @@ -223,6 +224,6 @@ public Set list(ProjectId projectId) { } protected boolean viewsFeatureEnabled() { - return EsqlFeatures.ESQL_VIEWS_FEATURE_FLAG.isEnabled(); + return EsqlFeatureFlags.ESQL_VIEWS_FEATURE_FLAG.isEnabled(); } } diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/view/ViewRestTests.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/view/ViewRestTests.java index 89b5e5d8709a0..7ac7ae5df4376 100644 --- a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/view/ViewRestTests.java +++ b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/view/ViewRestTests.java @@ -9,7 +9,7 @@ import org.elasticsearch.core.TimeValue; -import static org.elasticsearch.xpack.esql.plugin.EsqlFeatures.ESQL_VIEWS_FEATURE_FLAG; +import static org.elasticsearch.xpack.core.esql.EsqlFeatureFlags.ESQL_VIEWS_FEATURE_FLAG; public class ViewRestTests extends AbstractViewTestCase { diff --git a/x-pack/plugin/security/qa/operator-privileges-tests/src/javaRestTest/java/org/elasticsearch/xpack/security/operator/Constants.java b/x-pack/plugin/security/qa/operator-privileges-tests/src/javaRestTest/java/org/elasticsearch/xpack/security/operator/Constants.java index 430e88cd9b1dd..b45707b9f4c66 100644 --- a/x-pack/plugin/security/qa/operator-privileges-tests/src/javaRestTest/java/org/elasticsearch/xpack/security/operator/Constants.java +++ b/x-pack/plugin/security/qa/operator-privileges-tests/src/javaRestTest/java/org/elasticsearch/xpack/security/operator/Constants.java @@ -175,9 +175,6 @@ public class Constants { "cluster:admin/xpack/enrich/get", "cluster:admin/xpack/enrich/put", "cluster:admin/xpack/enrich/reindex", - "cluster:admin/xpack/esql/view/put", - "cluster:admin/xpack/esql/view/delete", - "cluster:admin/xpack/esql/view/get", "cluster:admin/xpack/inference/ccm/delete", "cluster:admin/xpack/inference/ccm/put", "cluster:admin/xpack/inference/delete", @@ -536,6 +533,9 @@ public class Constants { "indices:admin/data_stream/options/get", "indices:admin/data_stream/options/put", "indices:admin/delete", + "indices:admin/esql/view/delete", + "indices:admin/esql/view/get", + "indices:admin/esql/view/put", "indices:admin/flush", "indices:admin/flush[s]", "indices:admin/forcemerge", diff --git a/x-pack/plugin/src/yamlRestTest/resources/rest-api-spec/test/privileges/11_builtin.yml b/x-pack/plugin/src/yamlRestTest/resources/rest-api-spec/test/privileges/11_builtin.yml index 2e501ad016592..192d607bad0f3 100644 --- a/x-pack/plugin/src/yamlRestTest/resources/rest-api-spec/test/privileges/11_builtin.yml +++ b/x-pack/plugin/src/yamlRestTest/resources/rest-api-spec/test/privileges/11_builtin.yml @@ -16,4 +16,4 @@ setup: # I would much prefer we could just check that specific entries are in the array, but we don't have # an assertion for that - length: { "cluster" : 63 } - - length: { "index" : 24 } + - length: { "index" : 28 }