diff --git a/client/rest-high-level/src/main/java/org/elasticsearch/client/SecurityClient.java b/client/rest-high-level/src/main/java/org/elasticsearch/client/SecurityClient.java index fba8c6ed2bd14..4fdf8907f5572 100644 --- a/client/rest-high-level/src/main/java/org/elasticsearch/client/SecurityClient.java +++ b/client/rest-high-level/src/main/java/org/elasticsearch/client/SecurityClient.java @@ -23,6 +23,8 @@ import org.elasticsearch.client.security.AuthenticateRequest; import org.elasticsearch.client.security.AuthenticateResponse; import org.elasticsearch.client.security.ChangePasswordRequest; +import org.elasticsearch.client.security.ClearPrivilegesCacheRequest; +import org.elasticsearch.client.security.ClearPrivilegesCacheResponse; import org.elasticsearch.client.security.ClearRealmCacheRequest; import org.elasticsearch.client.security.ClearRealmCacheResponse; import org.elasticsearch.client.security.ClearRolesCacheRequest; @@ -510,6 +512,38 @@ public Cancellable clearRolesCacheAsync(ClearRolesCacheRequest request, RequestO ClearRolesCacheResponse::fromXContent, listener, emptySet()); } + /** + * Clears the privileges cache for a set of privileges. + * See + * the docs for more. + * + * @param request the request with the privileges for which the cache should be cleared. + * @param options the request options (e.g. headers), use {@link RequestOptions#DEFAULT} if nothing needs to be customized + * @return the response from the clear privileges cache call + * @throws IOException in case there is a problem sending the request or parsing back the response + */ + public ClearPrivilegesCacheResponse clearPrivilegesCache(ClearPrivilegesCacheRequest request, + RequestOptions options) throws IOException { + return restHighLevelClient.performRequestAndParseEntity(request, SecurityRequestConverters::clearPrivilegesCache, options, + ClearPrivilegesCacheResponse::fromXContent, emptySet()); + } + + /** + * Clears the privileges cache for a set of privileges asynchronously. + * See + * the docs for more. + * + * @param request the request with the privileges for which the cache should be cleared. + * @param options the request options (e.g. headers), use {@link RequestOptions#DEFAULT} if nothing needs to be customized + * @param listener the listener to be notified upon request completion + * @return cancellable that may be used to cancel the request + */ + public Cancellable clearPrivilegesCacheAsync(ClearPrivilegesCacheRequest request, RequestOptions options, + ActionListener listener) { + return restHighLevelClient.performRequestAsyncAndParseEntity(request, SecurityRequestConverters::clearPrivilegesCache, options, + ClearPrivilegesCacheResponse::fromXContent, listener, emptySet()); + } + /** * Synchronously retrieve the X.509 certificates that are used to encrypt communications in an Elasticsearch cluster. * See diff --git a/client/rest-high-level/src/main/java/org/elasticsearch/client/SecurityRequestConverters.java b/client/rest-high-level/src/main/java/org/elasticsearch/client/SecurityRequestConverters.java index c88d1d180fcc8..55301aed30752 100644 --- a/client/rest-high-level/src/main/java/org/elasticsearch/client/SecurityRequestConverters.java +++ b/client/rest-high-level/src/main/java/org/elasticsearch/client/SecurityRequestConverters.java @@ -24,6 +24,7 @@ import org.apache.http.client.methods.HttpPost; import org.apache.http.client.methods.HttpPut; import org.elasticsearch.client.security.ChangePasswordRequest; +import org.elasticsearch.client.security.ClearPrivilegesCacheRequest; import org.elasticsearch.client.security.ClearRealmCacheRequest; import org.elasticsearch.client.security.ClearRolesCacheRequest; import org.elasticsearch.client.security.CreateApiKeyRequest; @@ -183,6 +184,15 @@ static Request clearRolesCache(ClearRolesCacheRequest disableCacheRequest) { return new Request(HttpPost.METHOD_NAME, endpoint); } + static Request clearPrivilegesCache(ClearPrivilegesCacheRequest disableCacheRequest) { + String endpoint = new RequestConverters.EndpointBuilder() + .addPathPartAsIs("_security/privilege") + .addCommaSeparatedPathParts(disableCacheRequest.applications()) + .addPathPart("_clear_cache") + .build(); + return new Request(HttpPost.METHOD_NAME, endpoint); + } + static Request deleteRoleMapping(DeleteRoleMappingRequest deleteRoleMappingRequest) { final String endpoint = new RequestConverters.EndpointBuilder() .addPathPartAsIs("_security/role_mapping") diff --git a/client/rest-high-level/src/main/java/org/elasticsearch/client/security/ClearPrivilegesCacheRequest.java b/client/rest-high-level/src/main/java/org/elasticsearch/client/security/ClearPrivilegesCacheRequest.java new file mode 100644 index 0000000000000..db6b2283e63b1 --- /dev/null +++ b/client/rest-high-level/src/main/java/org/elasticsearch/client/security/ClearPrivilegesCacheRequest.java @@ -0,0 +1,65 @@ +/* + * Licensed to Elasticsearch under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.elasticsearch.client.security; + +import org.elasticsearch.client.Validatable; + +import java.util.Arrays; + +/** + * The request used to clear the cache for native application privileges stored in an index. + */ +public final class ClearPrivilegesCacheRequest implements Validatable { + + private final String[] applications; + + /** + * Sets the applications for which caches will be evicted. When not set all privileges will be evicted from the cache. + * + * @param applications The application names + */ + public ClearPrivilegesCacheRequest(String... applications) { + this.applications = applications; + } + + /** + * @return an array of application names that will have the cache evicted or null if all + */ + public String[] applications() { + return applications; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + ClearPrivilegesCacheRequest that = (ClearPrivilegesCacheRequest) o; + return Arrays.equals(applications, that.applications); + } + + @Override + public int hashCode() { + return Arrays.hashCode(applications); + } +} diff --git a/client/rest-high-level/src/main/java/org/elasticsearch/client/security/ClearPrivilegesCacheResponse.java b/client/rest-high-level/src/main/java/org/elasticsearch/client/security/ClearPrivilegesCacheResponse.java new file mode 100644 index 0000000000000..d6b62123b6595 --- /dev/null +++ b/client/rest-high-level/src/main/java/org/elasticsearch/client/security/ClearPrivilegesCacheResponse.java @@ -0,0 +1,50 @@ +/* + * Licensed to Elasticsearch under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.elasticsearch.client.security; + +import org.elasticsearch.client.NodesResponseHeader; +import org.elasticsearch.common.xcontent.ConstructingObjectParser; +import org.elasticsearch.common.xcontent.XContentParser; + +import java.io.IOException; +import java.util.List; + +/** + * The response object that will be returned when clearing the privileges cache + */ +public final class ClearPrivilegesCacheResponse extends SecurityNodesResponse { + + @SuppressWarnings("unchecked") + private static final ConstructingObjectParser PARSER = + new ConstructingObjectParser<>("clear_privileges_cache_response", false, + args -> new ClearPrivilegesCacheResponse((List)args[0], (NodesResponseHeader) args[1], (String) args[2])); + + static { + SecurityNodesResponse.declareCommonNodesResponseParsing(PARSER); + } + + public ClearPrivilegesCacheResponse(List nodes, NodesResponseHeader header, String clusterName) { + super(nodes, header, clusterName); + } + + public static ClearPrivilegesCacheResponse fromXContent(XContentParser parser) throws IOException { + return PARSER.parse(parser, null); + } +} diff --git a/client/rest-high-level/src/test/java/org/elasticsearch/client/documentation/SecurityDocumentationIT.java b/client/rest-high-level/src/test/java/org/elasticsearch/client/documentation/SecurityDocumentationIT.java index c5ba7ba6011f3..5c1f454413c7c 100644 --- a/client/rest-high-level/src/test/java/org/elasticsearch/client/documentation/SecurityDocumentationIT.java +++ b/client/rest-high-level/src/test/java/org/elasticsearch/client/documentation/SecurityDocumentationIT.java @@ -30,6 +30,8 @@ import org.elasticsearch.client.security.AuthenticateResponse; import org.elasticsearch.client.security.AuthenticateResponse.RealmInfo; import org.elasticsearch.client.security.ChangePasswordRequest; +import org.elasticsearch.client.security.ClearPrivilegesCacheRequest; +import org.elasticsearch.client.security.ClearPrivilegesCacheResponse; import org.elasticsearch.client.security.ClearRealmCacheRequest; import org.elasticsearch.client.security.ClearRealmCacheResponse; import org.elasticsearch.client.security.ClearRolesCacheRequest; @@ -1003,6 +1005,52 @@ public void onFailure(Exception e) { } } + public void testClearPrivilegesCache() throws Exception { + RestHighLevelClient client = highLevelClient(); + { + //tag::clear-privileges-cache-request + ClearPrivilegesCacheRequest request = new ClearPrivilegesCacheRequest("my_app"); // <1> + //end::clear-privileges-cache-request + //tag::clear-privileges-cache-execute + ClearPrivilegesCacheResponse response = client.security().clearPrivilegesCache(request, RequestOptions.DEFAULT); + //end::clear-privileges-cache-execute + + assertNotNull(response); + assertThat(response.getNodes(), not(empty())); + + //tag::clear-privileges-cache-response + List nodes = response.getNodes(); // <1> + //end::clear-privileges-cache-response + } + + { + //tag::clear-privileges-cache-execute-listener + ClearPrivilegesCacheRequest request = new ClearPrivilegesCacheRequest("my_app"); + ActionListener listener = new ActionListener<>() { + @Override + public void onResponse(ClearPrivilegesCacheResponse clearPrivilegesCacheResponse) { + // <1> + } + + @Override + public void onFailure(Exception e) { + // <2> + } + }; + //end::clear-privileges-cache-execute-listener + + // Replace the empty listener by a blocking listener in test + final CountDownLatch latch = new CountDownLatch(1); + listener = new LatchedActionListener<>(listener, latch); + + // tag::clear-privileges-cache-execute-async + client.security().clearPrivilegesCacheAsync(request, RequestOptions.DEFAULT, listener); // <1> + // end::clear-privileges-cache-execute-async + + assertTrue(latch.await(30L, TimeUnit.SECONDS)); + } + } + public void testGetSslCertificates() throws Exception { RestHighLevelClient client = highLevelClient(); { diff --git a/docs/java-rest/high-level/security/clear-privileges-cache.asciidoc b/docs/java-rest/high-level/security/clear-privileges-cache.asciidoc new file mode 100644 index 0000000000000..2376c6a5bd88e --- /dev/null +++ b/docs/java-rest/high-level/security/clear-privileges-cache.asciidoc @@ -0,0 +1,33 @@ + +-- +:api: clear-privileges-cache +:request: ClearPrivilegesCacheRequest +:response: ClearPrivilegesCacheResponse +-- +[role="xpack"] +[id="{upid}-{api}"] +=== Clear Privileges Cache API + +[id="{upid}-{api}-request"] +==== Clear Privileges Cache Request + +A +{request}+ supports defining the name of applications that the cache should be cleared for. + +["source","java",subs="attributes,callouts,macros"] +-------------------------------------------------- +include-tagged::{doc-tests-file}[{api}-request] +-------------------------------------------------- +<1> the name of the application(s) for which the cache should be cleared + +include::../execution.asciidoc[] + +[id="{upid}-{api}-response"] +==== Clear Privileges Cache Response + +The returned +{response}+ allows to retrieve information about where the cache was cleared. + +["source","java",subs="attributes,callouts,macros"] +-------------------------------------------------- +include-tagged::{doc-tests-file}[{api}-response] +-------------------------------------------------- +<1> the list of nodes that the cache was cleared on diff --git a/docs/java-rest/high-level/supported-apis.asciidoc b/docs/java-rest/high-level/supported-apis.asciidoc index fba4a96c78dd8..bf9ad52e35071 100644 --- a/docs/java-rest/high-level/supported-apis.asciidoc +++ b/docs/java-rest/high-level/supported-apis.asciidoc @@ -455,6 +455,7 @@ The Java High Level REST Client supports the following Security APIs: * <<{upid}-get-roles>> * <> * <<{upid}-clear-roles-cache>> +* <<{upid}-clear-privileges-cache>> * <<{upid}-clear-realm-cache>> * <<{upid}-authenticate>> * <<{upid}-has-privileges>> @@ -486,6 +487,7 @@ include::security/delete-privileges.asciidoc[] include::security/get-builtin-privileges.asciidoc[] include::security/get-privileges.asciidoc[] include::security/clear-roles-cache.asciidoc[] +include::security/clear-privileges-cache.asciidoc[] include::security/clear-realm-cache.asciidoc[] include::security/authenticate.asciidoc[] include::security/has-privileges.asciidoc[] diff --git a/x-pack/docs/en/rest-api/security.asciidoc b/x-pack/docs/en/rest-api/security.asciidoc index 215ad2d1c25a9..5a81fc8d2c7bb 100644 --- a/x-pack/docs/en/rest-api/security.asciidoc +++ b/x-pack/docs/en/rest-api/security.asciidoc @@ -15,10 +15,11 @@ You can use the following APIs to perform security activities. [[security-api-app-privileges]] === Application privileges -You can use the following APIs to add, update, retrieve, and remove application +You can use the following APIs to add, update, retrieve, and remove application privileges: -* <> +* <> +* <> * <> * <> @@ -28,7 +29,7 @@ privileges: You can use the following APIs to add, remove, update, and retrieve role mappings: -* <> +* <> * <> * <> @@ -106,6 +107,7 @@ include::security/authenticate.asciidoc[] include::security/change-password.asciidoc[] include::security/clear-cache.asciidoc[] include::security/clear-roles-cache.asciidoc[] +include::security/clear-privileges-cache.asciidoc[] include::security/create-api-keys.asciidoc[] include::security/put-app-privileges.asciidoc[] include::security/create-role-mappings.asciidoc[] diff --git a/x-pack/docs/en/rest-api/security/clear-cache.asciidoc b/x-pack/docs/en/rest-api/security/clear-cache.asciidoc index 2a1a227163da0..fcae76cb99779 100644 --- a/x-pack/docs/en/rest-api/security/clear-cache.asciidoc +++ b/x-pack/docs/en/rest-api/security/clear-cache.asciidoc @@ -25,8 +25,10 @@ There are realm settings that you can use to configure the user cache. For more information, see <>. -To evict roles from the role cache, see the +To evict roles from the role cache, see the <>. +To evict privileges from the privilege cache, see the +<>. [[security-api-clear-path-params]] ==== {api-path-parms-title} diff --git a/x-pack/docs/en/rest-api/security/clear-privileges-cache.asciidoc b/x-pack/docs/en/rest-api/security/clear-privileges-cache.asciidoc new file mode 100644 index 0000000000000..71dbeebcb3d4a --- /dev/null +++ b/x-pack/docs/en/rest-api/security/clear-privileges-cache.asciidoc @@ -0,0 +1,43 @@ +[role="xpack"] +[[security-api-clear-privilege-cache]] +=== Clear privileges cache API +++++ +Clear privileges cache +++++ + +Evicts privileges from the native application privilege cache. +The cache is also automatically cleared for applications that have their privileges updated. + +[[security-api-clear-privilege-cache-request]] +==== {api-request-title} + +`POST /_security/privilege//_clear_cache` + +[[security-api-clear-privilege-cache-prereqs]] +==== {api-prereq-title} + +* To use this API, you must have at least the `manage_security` cluster +privilege. + +[[security-api-clear-privilege-cache-desc]] +==== {api-description-title} + +For more information about the native realm, see +<> and <>. + +[[security-api-clear-privilege-cache-path-params]] +==== {api-path-parms-title} + +`application`:: + (string) The name of the application. If omitted, all entries are evicted from the cache. + +[[security-api-clear-privilege-cache-example]] +==== {api-examples-title} + +The clear privileges cache API evicts privileges from the native application privilege cache. +For example, to clear the cache for `myapp`: + +[source,console] +-------------------------------------------------- +POST /_security/privilege/myapp/_clear_cache +-------------------------------------------------- diff --git a/x-pack/docs/en/rest-api/security/delete-app-privileges.asciidoc b/x-pack/docs/en/rest-api/security/delete-app-privileges.asciidoc index 0ff3ecc8b4aec..39ac1706c6dc2 100644 --- a/x-pack/docs/en/rest-api/security/delete-app-privileges.asciidoc +++ b/x-pack/docs/en/rest-api/security/delete-app-privileges.asciidoc @@ -10,7 +10,7 @@ Removes <>. [[security-api-delete-privilege-request]] ==== {api-request-title} -`DELETE /_security/privilege//` +`DELETE /_security/privilege//` [[security-api-delete-privilege-prereqs]] ==== {api-prereq-title} @@ -34,16 +34,16 @@ To use this API, you must have either: [[security-api-delete-privilege-example]] ==== {api-examples-title} -The following example deletes the `read` application privilege from the +The following example deletes the `read` application privilege from the `myapp` application: [source,console] -------------------------------------------------- DELETE /_security/privilege/myapp/read -------------------------------------------------- -// TEST[setup:app0102_privileges] +// TEST[setup:app0102_privileges] -If the role is successfully deleted, the request returns `{"found": true}`. +If the privilege is successfully deleted, the request returns `{"found": true}`. Otherwise, `found` is set to false. [source,console-result] diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/privilege/ClearPrivilegesCacheAction.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/privilege/ClearPrivilegesCacheAction.java new file mode 100644 index 0000000000000..2fbe2e0639112 --- /dev/null +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/privilege/ClearPrivilegesCacheAction.java @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +package org.elasticsearch.xpack.core.security.action.privilege; + +import org.elasticsearch.action.ActionType; + +public class ClearPrivilegesCacheAction extends ActionType { + + public static final ClearPrivilegesCacheAction INSTANCE = new ClearPrivilegesCacheAction(); + public static final String NAME = "cluster:admin/xpack/security/privilege/cache/clear"; + + protected ClearPrivilegesCacheAction() { + super(NAME, ClearPrivilegesCacheResponse::new); + } +} diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/privilege/ClearPrivilegesCacheRequest.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/privilege/ClearPrivilegesCacheRequest.java new file mode 100644 index 0000000000000..04acc372dfb56 --- /dev/null +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/privilege/ClearPrivilegesCacheRequest.java @@ -0,0 +1,86 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +package org.elasticsearch.xpack.core.security.action.privilege; + +import org.elasticsearch.action.support.nodes.BaseNodesRequest; +import org.elasticsearch.common.io.stream.StreamInput; +import org.elasticsearch.common.io.stream.StreamOutput; +import org.elasticsearch.transport.TransportRequest; + +import java.io.IOException; + +public class ClearPrivilegesCacheRequest extends BaseNodesRequest { + + private String[] applicationNames; + private boolean clearRolesCache = false; + + public ClearPrivilegesCacheRequest() { + super((String[]) null); + } + + public ClearPrivilegesCacheRequest(StreamInput in) throws IOException { + super(in); + applicationNames = in.readOptionalStringArray(); + clearRolesCache = in.readBoolean(); + } + + public ClearPrivilegesCacheRequest applicationNames(String... applicationNames) { + this.applicationNames = applicationNames; + return this; + } + + public ClearPrivilegesCacheRequest clearRolesCache(boolean clearRolesCache) { + this.clearRolesCache = clearRolesCache; + return this; + } + + public String[] applicationNames() { + return applicationNames; + } + + public boolean clearRolesCache() { + return clearRolesCache; + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + super.writeTo(out); + out.writeOptionalStringArray(applicationNames); + out.writeBoolean(clearRolesCache); + } + + public static class Node extends TransportRequest { + private String[] applicationNames; + private boolean clearRolesCache; + + public Node(StreamInput in) throws IOException { + super(in); + applicationNames = in.readOptionalStringArray(); + clearRolesCache = in.readBoolean(); + } + + public Node(ClearPrivilegesCacheRequest request) { + this.applicationNames = request.applicationNames(); + this.clearRolesCache = request.clearRolesCache; + } + + public String[] getApplicationNames() { + return applicationNames; + } + + public boolean clearRolesCache() { + return clearRolesCache; + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + super.writeTo(out); + out.writeOptionalStringArray(applicationNames); + out.writeBoolean(clearRolesCache); + } + } +} diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/privilege/ClearPrivilegesCacheResponse.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/privilege/ClearPrivilegesCacheResponse.java new file mode 100644 index 0000000000000..85dcc3b94650f --- /dev/null +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/privilege/ClearPrivilegesCacheResponse.java @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +package org.elasticsearch.xpack.core.security.action.privilege; + +import org.elasticsearch.action.FailedNodeException; +import org.elasticsearch.action.support.nodes.BaseNodeResponse; +import org.elasticsearch.action.support.nodes.BaseNodesResponse; +import org.elasticsearch.cluster.ClusterName; +import org.elasticsearch.cluster.node.DiscoveryNode; +import org.elasticsearch.common.io.stream.StreamInput; +import org.elasticsearch.common.io.stream.StreamOutput; +import org.elasticsearch.common.xcontent.ToXContentFragment; +import org.elasticsearch.common.xcontent.XContentBuilder; + +import java.io.IOException; +import java.util.List; + +public class ClearPrivilegesCacheResponse extends BaseNodesResponse + implements ToXContentFragment { + + public ClearPrivilegesCacheResponse(StreamInput in) throws IOException { + super(in); + } + + public ClearPrivilegesCacheResponse(ClusterName clusterName, List nodes, List failures) { + super(clusterName, nodes, failures); + } + + @Override + protected List readNodesFrom(StreamInput in) throws IOException { + return in.readList(Node::new); + } + + @Override + protected void writeNodesTo(StreamOutput out, List nodes) throws IOException { + out.writeList(nodes); + } + + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + builder.startObject("nodes"); + for (Node node : getNodes()) { + builder.startObject(node.getNode().getId()); + builder.field("name", node.getNode().getName()); + builder.endObject(); + } + builder.endObject(); + return builder; + } + + public static class Node extends BaseNodeResponse { + public Node(StreamInput in) throws IOException { + super(in); + } + + public Node(DiscoveryNode node) { + super(node); + } + } +} diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java index 3b0f1b1228313..a272c506fa1ea 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java @@ -89,6 +89,7 @@ import org.elasticsearch.xpack.core.security.action.oidc.OpenIdConnectAuthenticateAction; import org.elasticsearch.xpack.core.security.action.oidc.OpenIdConnectLogoutAction; import org.elasticsearch.xpack.core.security.action.oidc.OpenIdConnectPrepareAuthenticationAction; +import org.elasticsearch.xpack.core.security.action.privilege.ClearPrivilegesCacheAction; import org.elasticsearch.xpack.core.security.action.privilege.DeletePrivilegesAction; import org.elasticsearch.xpack.core.security.action.privilege.GetBuiltinPrivilegesAction; import org.elasticsearch.xpack.core.security.action.privilege.GetPrivilegesAction; @@ -153,6 +154,7 @@ import org.elasticsearch.xpack.security.action.oidc.TransportOpenIdConnectAuthenticateAction; import org.elasticsearch.xpack.security.action.oidc.TransportOpenIdConnectLogoutAction; import org.elasticsearch.xpack.security.action.oidc.TransportOpenIdConnectPrepareAuthenticationAction; +import org.elasticsearch.xpack.security.action.privilege.TransportClearPrivilegesCacheAction; import org.elasticsearch.xpack.security.action.privilege.TransportDeletePrivilegesAction; import org.elasticsearch.xpack.security.action.privilege.TransportGetBuiltinPrivilegesAction; import org.elasticsearch.xpack.security.action.privilege.TransportGetPrivilegesAction; @@ -220,6 +222,7 @@ import org.elasticsearch.xpack.security.rest.action.oidc.RestOpenIdConnectAuthenticateAction; import org.elasticsearch.xpack.security.rest.action.oidc.RestOpenIdConnectLogoutAction; import org.elasticsearch.xpack.security.rest.action.oidc.RestOpenIdConnectPrepareAuthenticationAction; +import org.elasticsearch.xpack.security.rest.action.privilege.RestClearPrivilegesCacheAction; import org.elasticsearch.xpack.security.rest.action.privilege.RestDeletePrivilegesAction; import org.elasticsearch.xpack.security.rest.action.privilege.RestGetBuiltinPrivilegesAction; import org.elasticsearch.xpack.security.rest.action.privilege.RestGetPrivilegesAction; @@ -430,6 +433,7 @@ Collection createComponents(Client client, ThreadPool threadPool, Cluste final NativePrivilegeStore privilegeStore = new NativePrivilegeStore(settings, client, securityIndex.get()); components.add(privilegeStore); + securityIndex.get().addIndexStateListener(privilegeStore::onSecurityIndexStateChange); dlsBitsetCache.set(new DocumentSubsetBitsetCache(settings, threadPool)); final FieldPermissionsCache fieldPermissionsCache = new FieldPermissionsCache(settings); @@ -645,6 +649,8 @@ public static List> getSettings(List securityExten settingsList.add(ApiKeyService.CACHE_HASH_ALGO_SETTING); settingsList.add(ApiKeyService.CACHE_MAX_KEYS_SETTING); settingsList.add(ApiKeyService.CACHE_TTL_SETTING); + settingsList.add(NativePrivilegeStore.CACHE_MAX_APPLICATIONS_SETTING); + settingsList.add(NativePrivilegeStore.CACHE_TTL_SETTING); // hide settings settingsList.add(Setting.listSetting(SecurityField.setting("hide_settings"), Collections.emptyList(), Function.identity(), @@ -732,6 +738,7 @@ public void onIndexModule(IndexModule module) { return Arrays.asList( new ActionHandler<>(ClearRealmCacheAction.INSTANCE, TransportClearRealmCacheAction.class), new ActionHandler<>(ClearRolesCacheAction.INSTANCE, TransportClearRolesCacheAction.class), + new ActionHandler<>(ClearPrivilegesCacheAction.INSTANCE, TransportClearPrivilegesCacheAction.class), new ActionHandler<>(GetUsersAction.INSTANCE, TransportGetUsersAction.class), new ActionHandler<>(PutUserAction.INSTANCE, TransportPutUserAction.class), new ActionHandler<>(DeleteUserAction.INSTANCE, TransportDeleteUserAction.class), @@ -792,6 +799,7 @@ public List getRestHandlers(Settings settings, RestController restC new RestAuthenticateAction(settings, securityContext.get(), getLicenseState()), new RestClearRealmCacheAction(settings, getLicenseState()), new RestClearRolesCacheAction(settings, getLicenseState()), + new RestClearPrivilegesCacheAction(settings, getLicenseState()), new RestGetUsersAction(settings, getLicenseState()), new RestPutUserAction(settings, getLicenseState()), new RestDeleteUserAction(settings, getLicenseState()), diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/privilege/TransportClearPrivilegesCacheAction.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/privilege/TransportClearPrivilegesCacheAction.java new file mode 100644 index 0000000000000..563c4b27ede91 --- /dev/null +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/privilege/TransportClearPrivilegesCacheAction.java @@ -0,0 +1,83 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +package org.elasticsearch.xpack.security.action.privilege; + +import org.elasticsearch.action.FailedNodeException; +import org.elasticsearch.action.support.ActionFilters; +import org.elasticsearch.action.support.nodes.TransportNodesAction; +import org.elasticsearch.cluster.service.ClusterService; +import org.elasticsearch.common.inject.Inject; +import org.elasticsearch.common.io.stream.StreamInput; +import org.elasticsearch.tasks.Task; +import org.elasticsearch.threadpool.ThreadPool; +import org.elasticsearch.transport.TransportService; +import org.elasticsearch.xpack.core.security.action.privilege.ClearPrivilegesCacheAction; +import org.elasticsearch.xpack.core.security.action.privilege.ClearPrivilegesCacheRequest; +import org.elasticsearch.xpack.core.security.action.privilege.ClearPrivilegesCacheResponse; +import org.elasticsearch.xpack.security.authz.store.CompositeRolesStore; +import org.elasticsearch.xpack.security.authz.store.NativePrivilegeStore; + +import java.io.IOException; +import java.util.List; + +public class TransportClearPrivilegesCacheAction extends TransportNodesAction { + + private final NativePrivilegeStore privilegesStore; + private final CompositeRolesStore rolesStore; + + @Inject + public TransportClearPrivilegesCacheAction( + ThreadPool threadPool, + ClusterService clusterService, + TransportService transportService, + ActionFilters actionFilters, + NativePrivilegeStore privilegesStore, + CompositeRolesStore rolesStore) { + super( + ClearPrivilegesCacheAction.NAME, + threadPool, + clusterService, + transportService, + actionFilters, + ClearPrivilegesCacheRequest::new, + ClearPrivilegesCacheRequest.Node::new, + ThreadPool.Names.MANAGEMENT, + ClearPrivilegesCacheResponse.Node.class); + this.privilegesStore = privilegesStore; + this.rolesStore = rolesStore; + } + + @Override + protected ClearPrivilegesCacheResponse newResponse( + ClearPrivilegesCacheRequest request, List nodes, List failures) { + return new ClearPrivilegesCacheResponse(clusterService.getClusterName(), nodes, failures); + } + + @Override + protected ClearPrivilegesCacheRequest.Node newNodeRequest(ClearPrivilegesCacheRequest request) { + return new ClearPrivilegesCacheRequest.Node(request); + } + + @Override + protected ClearPrivilegesCacheResponse.Node newNodeResponse(StreamInput in) throws IOException { + return new ClearPrivilegesCacheResponse.Node(in); + } + + @Override + protected ClearPrivilegesCacheResponse.Node nodeOperation(ClearPrivilegesCacheRequest.Node request, Task task) { + if (request.getApplicationNames() == null || request.getApplicationNames().length == 0) { + privilegesStore.invalidateAll(); + } else { + privilegesStore.invalidate(List.of(request.getApplicationNames())); + } + if (request.clearRolesCache()) { + rolesStore.invalidateAll(); + } + return new ClearPrivilegesCacheResponse.Node(clusterService.localNode()); + } +} diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/store/NativePrivilegeStore.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/store/NativePrivilegeStore.java index 7142f6243d952..e46c5d3b5657d 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/store/NativePrivilegeStore.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/store/NativePrivilegeStore.java @@ -12,22 +12,24 @@ import org.elasticsearch.action.ActionListener; import org.elasticsearch.action.DocWriteResponse; import org.elasticsearch.action.delete.DeleteResponse; -import org.elasticsearch.action.get.GetResponse; import org.elasticsearch.action.index.IndexResponse; import org.elasticsearch.action.search.SearchRequest; import org.elasticsearch.action.support.ContextPreservingActionListener; import org.elasticsearch.action.support.GroupedActionListener; -import org.elasticsearch.action.support.TransportActions; import org.elasticsearch.action.support.WriteRequest; import org.elasticsearch.client.Client; import org.elasticsearch.common.Strings; import org.elasticsearch.common.bytes.BytesReference; +import org.elasticsearch.common.cache.Cache; +import org.elasticsearch.common.cache.CacheBuilder; import org.elasticsearch.common.collect.Tuple; import org.elasticsearch.common.io.stream.StreamInput; +import org.elasticsearch.common.settings.Setting; import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.common.unit.TimeValue; import org.elasticsearch.common.util.CollectionUtils; +import org.elasticsearch.common.util.concurrent.ReleasableLock; import org.elasticsearch.common.util.concurrent.ThreadContext; -import org.elasticsearch.common.util.iterable.Iterables; import org.elasticsearch.common.xcontent.LoggingDeprecationHandler; import org.elasticsearch.common.xcontent.NamedXContentRegistry; import org.elasticsearch.common.xcontent.XContentBuilder; @@ -41,9 +43,9 @@ import org.elasticsearch.index.query.TermsQueryBuilder; import org.elasticsearch.xpack.core.ClientHelper; import org.elasticsearch.xpack.core.security.ScrollHelper; -import org.elasticsearch.xpack.core.security.action.role.ClearRolesCacheAction; -import org.elasticsearch.xpack.core.security.action.role.ClearRolesCacheRequest; -import org.elasticsearch.xpack.core.security.action.role.ClearRolesCacheResponse; +import org.elasticsearch.xpack.core.security.action.privilege.ClearPrivilegesCacheAction; +import org.elasticsearch.xpack.core.security.action.privilege.ClearPrivilegesCacheRequest; +import org.elasticsearch.xpack.core.security.action.privilege.ClearPrivilegesCacheResponse; import org.elasticsearch.xpack.core.security.authz.privilege.ApplicationPrivilegeDescriptor; import org.elasticsearch.xpack.security.support.SecurityIndexManager; @@ -51,9 +53,13 @@ import java.util.ArrayList; import java.util.Collection; import java.util.Collections; +import java.util.HashSet; import java.util.List; import java.util.Map; -import java.util.Objects; +import java.util.Set; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.locks.ReadWriteLock; +import java.util.concurrent.locks.ReentrantReadWriteLock; import java.util.function.Supplier; import java.util.stream.Collector; import java.util.stream.Collectors; @@ -65,6 +71,8 @@ import static org.elasticsearch.xpack.core.security.authz.privilege.ApplicationPrivilegeDescriptor.DOC_TYPE_VALUE; import static org.elasticsearch.xpack.core.security.authz.privilege.ApplicationPrivilegeDescriptor.Fields.APPLICATION; import static org.elasticsearch.xpack.core.security.index.RestrictedIndicesNames.SECURITY_MAIN_ALIAS; +import static org.elasticsearch.xpack.security.support.SecurityIndexManager.isIndexDeleted; +import static org.elasticsearch.xpack.security.support.SecurityIndexManager.isMoveFromRedToNonRed; /** * {@code NativePrivilegeStore} is a store that reads/writes {@link ApplicationPrivilegeDescriptor} objects, @@ -72,6 +80,14 @@ */ public class NativePrivilegeStore { + + public static final Setting CACHE_MAX_APPLICATIONS_SETTING = + Setting.intSetting("xpack.security.authz.store.privileges.cache.max_size", + 10_000, Setting.Property.NodeScope); + + public static final Setting CACHE_TTL_SETTING = Setting.timeSetting("xpack.security.authz.store.privileges.cache.ttl", + TimeValue.timeValueHours(24L), Setting.Property.NodeScope); + private static final Collector, ?, Map>> TUPLES_TO_MAP = Collectors.toMap( Tuple::v1, t -> CollectionUtils.newSingletonArrayList(t.v2()), (a, b) -> { @@ -83,47 +99,98 @@ public class NativePrivilegeStore { private final Settings settings; private final Client client; private final SecurityIndexManager securityIndexManager; + private final Cache> descriptorsCache; + private final Cache, Set> applicationNamesCache; + private final AtomicLong numInvalidation = new AtomicLong(); + private final ReadWriteLock invalidationLock = new ReentrantReadWriteLock(); + private final ReleasableLock invalidationReadLock = new ReleasableLock(invalidationLock.readLock()); + private final ReleasableLock invalidationWriteLock = new ReleasableLock(invalidationLock.writeLock()); + public NativePrivilegeStore(Settings settings, Client client, SecurityIndexManager securityIndexManager) { this.settings = settings; this.client = client; this.securityIndexManager = securityIndexManager; + final TimeValue ttl = CACHE_TTL_SETTING.get(settings); + if (ttl.getNanos() > 0) { + final int cacheSize = CACHE_MAX_APPLICATIONS_SETTING.get(settings); + descriptorsCache = CacheBuilder.>builder() + .setMaximumWeight(cacheSize) + .weigher((k, v) -> v.size()) + .setExpireAfterWrite(ttl).build(); + applicationNamesCache = CacheBuilder., Set>builder() + .setMaximumWeight(cacheSize) + .weigher((k, v) -> k.size() + v.size()) + .setExpireAfterWrite(ttl).build(); + } else { + descriptorsCache = null; + applicationNamesCache = null; + } + assert (descriptorsCache == null && applicationNamesCache == null) + || (descriptorsCache != null && applicationNamesCache != null) + : "descriptor and application names cache must be enabled or disabled together"; } public void getPrivileges(Collection applications, Collection names, ActionListener> listener) { + + // TODO: We should have a way to express true Zero applications + final Set applicationNamesCacheKey = (isEmpty(applications) || applications.contains("*")) ? + Set.of("*") : Set.copyOf(applications); + + // Always fetch for the concrete application names even when the passed-in application names has no wildcard. + // This serves as a negative lookup, i.e. when a passed-in non-wildcard application does not exist. + Set concreteApplicationNames = applicationNamesCache == null ? null : applicationNamesCache.get(applicationNamesCacheKey); + + if (concreteApplicationNames != null && concreteApplicationNames.size() == 0) { + logger.debug("returning empty application privileges for [{}] as application names result in empty list", + applicationNamesCacheKey); + listener.onResponse(Collections.emptySet()); + } else { + final Set cachedDescriptors = cachedDescriptorsForApplicationNames( + concreteApplicationNames != null ? concreteApplicationNames : applicationNamesCacheKey); + if (cachedDescriptors != null) { + logger.debug("All application privileges for [{}] found in cache", applicationNamesCacheKey); + listener.onResponse(filterDescriptorsForPrivilegeNames(cachedDescriptors, names)); + } else { + final long invalidationCounter = numInvalidation.get(); + // Always fetch all privileges of an application for caching purpose + logger.debug("Fetching application privilege documents for: {}", applicationNamesCacheKey); + innerGetPrivileges(applicationNamesCacheKey, ActionListener.wrap(fetchedDescriptors -> { + final Map> mapOfFetchedDescriptors = fetchedDescriptors.stream() + .collect(Collectors.groupingBy(ApplicationPrivilegeDescriptor::getApplication, Collectors.toUnmodifiableSet())); + if (descriptorsCache != null) { + // Use RWLock and atomic counter to: + // 1. Avoid caching potential stale results as much as possible + // 2. If stale results are cached, ensure they will be invalidated as soon as possible + try (ReleasableLock ignored = invalidationReadLock.acquire()) { + if (invalidationCounter == numInvalidation.get()) { + cacheFetchedDescriptors(applicationNamesCacheKey, mapOfFetchedDescriptors); + } + } + } + listener.onResponse(filterDescriptorsForPrivilegeNames(fetchedDescriptors, names)); + }, listener::onFailure)); + } + } + } + + private void innerGetPrivileges(Collection applications, ActionListener> listener) { + assert applications != null && applications.size() > 0 : "Application names are required (found " + applications + ")"; + final SecurityIndexManager frozenSecurityIndex = securityIndexManager.freeze(); if (frozenSecurityIndex.indexExists() == false) { listener.onResponse(Collections.emptyList()); } else if (frozenSecurityIndex.isAvailable() == false) { listener.onFailure(frozenSecurityIndex.getUnavailableReason()); - } else if (isSinglePrivilegeMatch(applications, names)) { - getPrivilege(Objects.requireNonNull(Iterables.get(applications, 0)), Objects.requireNonNull(Iterables.get(names, 0)), - ActionListener.wrap(privilege -> - listener.onResponse(privilege == null ? Collections.emptyList() : Collections.singletonList(privilege)), - listener::onFailure)); } else { securityIndexManager.checkIndexVersionThenExecute(listener::onFailure, () -> { - final QueryBuilder query; + final TermQueryBuilder typeQuery = QueryBuilders .termQuery(ApplicationPrivilegeDescriptor.Fields.TYPE.getPreferredName(), DOC_TYPE_VALUE); - if (isEmpty(applications) && isEmpty(names)) { - query = typeQuery; - } else if (isEmpty(names)) { - query = QueryBuilders.boolQuery().filter(typeQuery).filter(getApplicationNameQuery(applications)); - } else if (isEmpty(applications)) { - query = QueryBuilders.boolQuery().filter(typeQuery) - .filter(getPrivilegeNameQuery(names)); - } else if (hasWildcard(applications)) { - query = QueryBuilders.boolQuery().filter(typeQuery) - .filter(getApplicationNameQuery(applications)) - .filter(getPrivilegeNameQuery(names)); - } else { - final String[] docIds = applications.stream() - .flatMap(a -> names.stream().map(n -> toDocId(a, n))) - .toArray(String[]::new); - query = QueryBuilders.boolQuery().filter(typeQuery).filter(QueryBuilders.idsQuery().addIds(docIds)); - } + final QueryBuilder query = QueryBuilders.boolQuery().filter(typeQuery) + .filter(getApplicationNameQuery(applications)); + final Supplier supplier = client.threadPool().getThreadContext().newRestorableContext(false); try (ThreadContext.StoredContext ignore = client.threadPool().getThreadContext().stashWithOrigin(SECURITY_ORIGIN)) { SearchRequest request = client.prepareSearch(SECURITY_MAIN_ALIAS) @@ -133,7 +200,8 @@ public void getPrivileges(Collection applications, Collection na .setFetchSource(true) .request(); logger.trace(() -> - new ParameterizedMessage("Searching for privileges [{}] with query [{}]", names, Strings.toString(query))); + new ParameterizedMessage("Searching for [{}] privileges with query [{}]", + applications, Strings.toString(query))); request.indicesOptions().ignoreUnavailable(); ScrollHelper.fetchAllByEntity(client, request, new ContextPreservingActionListener<>(supplier, listener), hit -> buildPrivilege(hit.getId(), hit.getSourceRef())); @@ -142,18 +210,6 @@ public void getPrivileges(Collection applications, Collection na } } - private boolean isSinglePrivilegeMatch(Collection applications, Collection names) { - return applications != null && applications.size() == 1 && hasWildcard(applications) == false && names != null && names.size() == 1; - } - - private boolean hasWildcard(Collection applications) { - return applications.stream().anyMatch(n -> n.endsWith("*")); - } - - private QueryBuilder getPrivilegeNameQuery(Collection names) { - return QueryBuilders.termsQuery(ApplicationPrivilegeDescriptor.Fields.NAME.getPreferredName(), names); - } - private QueryBuilder getApplicationNameQuery(Collection applications) { if (applications.contains("*")) { return QueryBuilders.existsQuery(APPLICATION.getPreferredName()); @@ -186,44 +242,82 @@ private QueryBuilder getApplicationNameQuery(Collection applications) { return boolQuery; } - private static boolean isEmpty(Collection collection) { - return collection == null || collection.isEmpty(); + private ApplicationPrivilegeDescriptor buildPrivilege(String docId, BytesReference source) { + logger.trace("Building privilege from [{}] [{}]", docId, source == null ? "<>" : source.utf8ToString()); + if (source == null) { + return null; + } + final Tuple name = nameFromDocId(docId); + try { + // EMPTY is safe here because we never use namedObject + + try (StreamInput input = source.streamInput(); + XContentParser parser = XContentType.JSON.xContent().createParser(NamedXContentRegistry.EMPTY, + LoggingDeprecationHandler.INSTANCE, input)) { + final ApplicationPrivilegeDescriptor privilege = ApplicationPrivilegeDescriptor.parse(parser, null, null, true); + assert privilege.getApplication().equals(name.v1()) + : "Incorrect application name for privilege. Expected [" + name.v1() + "] but was " + privilege.getApplication(); + assert privilege.getName().equals(name.v2()) + : "Incorrect name for application privilege. Expected [" + name.v2() + "] but was " + privilege.getName(); + return privilege; + } + } catch (IOException | XContentParseException e) { + logger.error(new ParameterizedMessage("cannot parse application privilege [{}]", name), e); + return null; + } } - void getPrivilege(String application, String name, ActionListener listener) { - final SecurityIndexManager frozenSecurityIndex = securityIndexManager.freeze(); - if (frozenSecurityIndex.isAvailable() == false) { - logger.warn(new ParameterizedMessage("failed to load privilege [{}] index not available", name), - frozenSecurityIndex.getUnavailableReason()); - listener.onResponse(null); - } else { - securityIndexManager.checkIndexVersionThenExecute(listener::onFailure, - () -> executeAsyncWithOrigin(client.threadPool().getThreadContext(), SECURITY_ORIGIN, - client.prepareGet(SECURITY_MAIN_ALIAS, toDocId(application, name)) - .request(), - new ActionListener() { - @Override - public void onResponse(GetResponse response) { - if (response.isExists()) { - listener.onResponse(buildPrivilege(response.getId(), response.getSourceAsBytesRef())); - } else { - listener.onResponse(null); - } - } + /** + * Try resolve all privileges for given application names from the cache. + * It returns non-null result only when privileges of ALL applications are + * found in the cache, i.e. it returns null if any of application name is + * NOT found in the cache. Since the cached is keyed by concrete application + * name, this means any wildcard will result in null. + */ + private Set cachedDescriptorsForApplicationNames(Set applicationNames) { + if (descriptorsCache == null) { + return null; + } + final Set cachedDescriptors = new HashSet<>(); + for (String applicationName: applicationNames) { + if (applicationName.endsWith("*")) { + return null; + } else { + final Set descriptors = descriptorsCache.get(applicationName); + if (descriptors == null) { + return null; + } else { + cachedDescriptors.addAll(descriptors); + } + } + } + return Collections.unmodifiableSet(cachedDescriptors); + } - @Override - public void onFailure(Exception e) { - // if the index or the shard is not there / available we just claim the privilege is not there - if (TransportActions.isShardNotAvailableException(e)) { - logger.warn(new ParameterizedMessage("failed to load privilege [{}] index not available", name), e); - listener.onResponse(null); - } else { - logger.error(new ParameterizedMessage("failed to load privilege [{}]", name), e); - listener.onFailure(e); - } - } - }, - client::get)); + /** + * Filter to get all privilege descriptors that have any of the given privilege names. + */ + private Collection filterDescriptorsForPrivilegeNames( + Collection descriptors, Collection privilegeNames) { + // empty set of names equals to retrieve everything + if (isEmpty(privilegeNames)) { + return descriptors; + } + return descriptors.stream().filter(d -> privilegeNames.contains(d.getName())).collect(Collectors.toUnmodifiableSet()); + } + + // protected for tests + protected void cacheFetchedDescriptors(Set applicationNamesCacheKey, + Map> mapOfFetchedDescriptors) { + final Set fetchedApplicationNames = Collections.unmodifiableSet(mapOfFetchedDescriptors.keySet()); + // Do not cache the names if expansion has no effect + if (fetchedApplicationNames.equals(applicationNamesCacheKey) == false) { + logger.debug("Caching application names query: {} = {}", applicationNamesCacheKey, fetchedApplicationNames); + applicationNamesCache.put(applicationNamesCacheKey, fetchedApplicationNames); + } + for (Map.Entry> entry : mapOfFetchedDescriptors.entrySet()) { + logger.debug("Caching descriptors for application: {}", entry.getKey()); + descriptorsCache.put(entry.getKey(), entry.getValue()); } } @@ -237,7 +331,9 @@ public void putPrivileges(Collection privileges, .map(r -> r.getId()) .map(NativePrivilegeStore::nameFromDocId) .collect(TUPLES_TO_MAP); - clearRolesCache(listener, createdNames); + clearCaches(listener, + privileges.stream().map(ApplicationPrivilegeDescriptor::getApplication).collect(Collectors.toUnmodifiableSet()), + createdNames); }, listener::onFailure), privileges.size()); for (ApplicationPrivilegeDescriptor privilege : privileges) { innerPutPrivilege(privilege, refreshPolicy, groupListener); @@ -259,7 +355,6 @@ private void innerPutPrivilege(ApplicationPrivilegeDescriptor privilege, WriteRe logger.warn("Failed to put privilege {} - {}", Strings.toString(privilege), e.toString()); listener.onFailure(e); } - } public void deletePrivileges(String application, Collection names, WriteRequest.RefreshPolicy refreshPolicy, @@ -278,7 +373,7 @@ public void deletePrivileges(String application, Collection names, Write .map(r -> r.getId()) .map(NativePrivilegeStore::nameFromDocId) .collect(TUPLES_TO_MAP); - clearRolesCache(listener, deletedNames); + clearCaches(listener, Collections.singleton(application), deletedNames); }, listener::onFailure), names.size()); for (String name : names) { ClientHelper.executeAsyncWithOrigin(client.threadPool().getThreadContext(), SECURITY_ORIGIN, @@ -290,50 +385,27 @@ public void deletePrivileges(String application, Collection names, Write } } - private void clearRolesCache(ActionListener listener, T value) { + private void clearCaches(ActionListener listener, Set applicationNames, T value) { // This currently clears _all_ roles, but could be improved to clear only those roles that reference the affected application - ClearRolesCacheRequest request = new ClearRolesCacheRequest(); - executeAsyncWithOrigin(client, SECURITY_ORIGIN, ClearRolesCacheAction.INSTANCE, request, + final ClearPrivilegesCacheRequest request = new ClearPrivilegesCacheRequest() + .applicationNames(applicationNames.toArray(String[]::new)).clearRolesCache(true); + executeAsyncWithOrigin(client, SECURITY_ORIGIN, ClearPrivilegesCacheAction.INSTANCE, request, new ActionListener<>() { @Override - public void onResponse(ClearRolesCacheResponse nodes) { + public void onResponse(ClearPrivilegesCacheResponse nodes) { listener.onResponse(value); } @Override public void onFailure(Exception e) { - logger.error("unable to clear role cache", e); + logger.error("unable to clear application privileges and role cache", e); listener.onFailure( - new ElasticsearchException("clearing the role cache failed. please clear the role cache manually", e)); + new ElasticsearchException("clearing the application privileges and role cache failed. " + + "please clear the caches manually", e)); } }); } - private ApplicationPrivilegeDescriptor buildPrivilege(String docId, BytesReference source) { - logger.trace("Building privilege from [{}] [{}]", docId, source == null ? "<>" : source.utf8ToString()); - if (source == null) { - return null; - } - final Tuple name = nameFromDocId(docId); - try { - // EMPTY is safe here because we never use namedObject - - try (StreamInput input = source.streamInput(); - XContentParser parser = XContentType.JSON.xContent().createParser(NamedXContentRegistry.EMPTY, - LoggingDeprecationHandler.INSTANCE, input)) { - final ApplicationPrivilegeDescriptor privilege = ApplicationPrivilegeDescriptor.parse(parser, null, null, true); - assert privilege.getApplication().equals(name.v1()) - : "Incorrect application name for privilege. Expected [" + name.v1() + "] but was " + privilege.getApplication(); - assert privilege.getName().equals(name.v2()) - : "Incorrect name for application privilege. Expected [" + name.v2() + "] but was " + privilege.getName(); - return privilege; - } - } catch (IOException | XContentParseException e) { - logger.error(new ParameterizedMessage("cannot parse application privilege [{}]", name), e); - return null; - } - } - private static Tuple nameFromDocId(String docId) { final String name = docId.substring(DOC_TYPE_VALUE.length() + 1); assert name != null && name.length() > 0 : "Invalid name '" + name + "'"; @@ -346,4 +418,60 @@ private static String toDocId(String application, String name) { return DOC_TYPE_VALUE + "_" + application + ":" + name; } + public void onSecurityIndexStateChange(SecurityIndexManager.State previousState, SecurityIndexManager.State currentState) { + if (isMoveFromRedToNonRed(previousState, currentState) || isIndexDeleted(previousState, currentState) + || previousState.isIndexUpToDate != currentState.isIndexUpToDate) { + invalidateAll(); + } + } + + public void invalidate(Collection updatedApplicationNames) { + if (descriptorsCache == null) { + return; + } + // Increment the invalidation count to avoid caching stale results + // Release the lock as soon as we increment the counter so the actual invalidation + // does not have to block other threads. In theory, there could be very rare edge + // cases that we would invalidate more than necessary. But we consider less locking + // duration is overall better. + try (ReleasableLock ignored = invalidationWriteLock.acquire()) { + numInvalidation.incrementAndGet(); + } + logger.debug("Invalidating application privileges caches for: {}", updatedApplicationNames); + final Set uniqueNames = Set.copyOf(updatedApplicationNames); + // Always completely invalidate application names cache due to wildcard + applicationNamesCache.invalidateAll(); + uniqueNames.forEach(descriptorsCache::invalidate); + } + + public void invalidateAll() { + if (descriptorsCache == null) { + return; + } + try (ReleasableLock ignored = invalidationWriteLock.acquire()) { + numInvalidation.incrementAndGet(); + } + logger.debug("Invalidating all application privileges caches"); + applicationNamesCache.invalidateAll(); + descriptorsCache.invalidateAll(); + } + + private static boolean isEmpty(Collection collection) { + return collection == null || collection.isEmpty(); + } + + // Package private for tests + Cache, Set> getApplicationNamesCache() { + return applicationNamesCache; + } + + // Package private for tests + Cache> getDescriptorsCache() { + return descriptorsCache; + } + + // Package private for tests + AtomicLong getNumInvalidation() { + return numInvalidation; + } } diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/rest/action/privilege/RestClearPrivilegesCacheAction.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/rest/action/privilege/RestClearPrivilegesCacheAction.java new file mode 100644 index 0000000000000..205234c297de4 --- /dev/null +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/rest/action/privilege/RestClearPrivilegesCacheAction.java @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +package org.elasticsearch.xpack.security.rest.action.privilege; + +import org.elasticsearch.client.node.NodeClient; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.license.XPackLicenseState; +import org.elasticsearch.rest.RestRequest; +import org.elasticsearch.rest.action.RestActions.NodesResponseRestListener; +import org.elasticsearch.xpack.core.security.action.privilege.ClearPrivilegesCacheAction; +import org.elasticsearch.xpack.core.security.action.privilege.ClearPrivilegesCacheRequest; +import org.elasticsearch.xpack.security.rest.action.SecurityBaseRestHandler; + +import java.io.IOException; +import java.util.Collections; +import java.util.List; + +import static org.elasticsearch.rest.RestRequest.Method.POST; + +public class RestClearPrivilegesCacheAction extends SecurityBaseRestHandler { + + public RestClearPrivilegesCacheAction(Settings settings, XPackLicenseState licenseState) { + super(settings, licenseState); + } + + @Override + public String getName() { + return "security_clear_privileges_cache_action"; + } + + @Override + public List routes() { + return Collections.singletonList(new Route(POST, "/_security/privilege/{application}/_clear_cache")); + } + + @Override + protected RestChannelConsumer innerPrepareRequest(RestRequest request, NodeClient client) throws IOException { + String[] applicationNames = request.paramAsStringArrayOrEmptyIfAll("application"); + final ClearPrivilegesCacheRequest req = new ClearPrivilegesCacheRequest().applicationNames(applicationNames); + return channel -> client.execute(ClearPrivilegesCacheAction.INSTANCE, req, new NodesResponseRestListener<>(channel)); + } + +} diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/store/NativePrivilegeStoreCacheTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/store/NativePrivilegeStoreCacheTests.java new file mode 100644 index 0000000000000..84808b04473cd --- /dev/null +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/store/NativePrivilegeStoreCacheTests.java @@ -0,0 +1,313 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +package org.elasticsearch.xpack.security.authz.store; + +import org.elasticsearch.ElasticsearchSecurityException; +import org.elasticsearch.action.ActionFuture; +import org.elasticsearch.action.DocWriteResponse; +import org.elasticsearch.action.admin.cluster.health.ClusterHealthAction; +import org.elasticsearch.action.admin.cluster.health.ClusterHealthRequestBuilder; +import org.elasticsearch.action.delete.DeleteRequest; +import org.elasticsearch.action.delete.DeleteResponse; +import org.elasticsearch.client.Client; +import org.elasticsearch.common.settings.SecureString; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.rest.RestStatus; +import org.elasticsearch.test.SecuritySingleNodeTestCase; +import org.elasticsearch.xpack.core.security.action.privilege.ClearPrivilegesCacheAction; +import org.elasticsearch.xpack.core.security.action.privilege.ClearPrivilegesCacheRequest; +import org.elasticsearch.xpack.core.security.action.privilege.ClearPrivilegesCacheResponse; +import org.elasticsearch.xpack.core.security.action.privilege.DeletePrivilegesRequestBuilder; +import org.elasticsearch.xpack.core.security.action.privilege.GetPrivilegesRequestBuilder; +import org.elasticsearch.xpack.core.security.action.privilege.PutPrivilegesAction; +import org.elasticsearch.xpack.core.security.action.privilege.PutPrivilegesRequest; +import org.elasticsearch.xpack.core.security.action.privilege.PutPrivilegesResponse; +import org.elasticsearch.xpack.core.security.action.role.PutRoleRequestBuilder; +import org.elasticsearch.xpack.core.security.action.role.PutRoleResponse; +import org.elasticsearch.xpack.core.security.action.user.HasPrivilegesAction; +import org.elasticsearch.xpack.core.security.action.user.HasPrivilegesRequest; +import org.elasticsearch.xpack.core.security.action.user.HasPrivilegesResponse; +import org.elasticsearch.xpack.core.security.action.user.PutUserRequestBuilder; +import org.elasticsearch.xpack.core.security.action.user.PutUserResponse; +import org.elasticsearch.xpack.core.security.authc.support.Hasher; +import org.elasticsearch.xpack.core.security.authz.RoleDescriptor; +import org.elasticsearch.xpack.core.security.authz.privilege.ApplicationPrivilegeDescriptor; +import org.junit.Before; + +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Base64; +import java.util.Collections; +import java.util.List; +import java.util.Set; + +import static java.util.Collections.emptyMap; +import static java.util.Collections.singleton; +import static java.util.Collections.singletonMap; +import static org.elasticsearch.action.support.WriteRequest.RefreshPolicy.IMMEDIATE; +import static org.elasticsearch.test.SecuritySettingsSource.TEST_PASSWORD_HASHED; +import static org.elasticsearch.test.SecuritySettingsSource.TEST_ROLE; +import static org.elasticsearch.test.SecuritySettingsSourceField.TEST_PASSWORD; +import static org.elasticsearch.xpack.core.security.authz.privilege.ApplicationPrivilegeDescriptor.DOC_TYPE_VALUE; +import static org.elasticsearch.xpack.core.security.index.RestrictedIndicesNames.SECURITY_MAIN_ALIAS; + +public class NativePrivilegeStoreCacheTests extends SecuritySingleNodeTestCase { + + private static final String APP_USER_NAME = "app_user"; + + @Override + protected String configUsers() { + return super.configUsers() + + APP_USER_NAME + ":" + TEST_PASSWORD_HASHED + "\n"; + } + + @Override + protected String configRoles() { + return super.configRoles() + + "app_role:\n" + + " cluster: ['monitor']\n" + + " indices:\n" + + " - names: ['*']\n" + + " privileges: ['read']\n" + + " applications:\n" + + " - application: 'app-1'\n" + + " privileges: ['read', 'check']\n" + + " resources: ['foo']\n" + + " - application: 'app-2'\n" + + " privileges: ['check']\n" + + " resources: ['foo']\n"; + } + + @Override + protected String configUsersRoles() { + return super.configUsersRoles() + + "app_role:" + APP_USER_NAME + "\n" + + TEST_ROLE + ":" + APP_USER_NAME + "\n"; + } + + @Override + protected Settings nodeSettings() { + Settings.Builder builder = Settings.builder().put(super.nodeSettings()); + // Ensure the new settings can be configured + builder.put("xpack.security.authz.store.privileges.cache.max_size", 5000); + builder.put("xpack.security.authz.store.privileges.cache.ttl", "12h"); + return builder.build(); + } + + @Before + public void configureApplicationPrivileges() { + final List applicationPrivilegeDescriptors = Arrays.asList( + new ApplicationPrivilegeDescriptor("app-1", "read", Set.of("r:a:b:c", "r:x:y:z"), emptyMap()), + new ApplicationPrivilegeDescriptor("app-1", "write", Set.of("w:a:b:c", "w:x:y:z"), emptyMap()), + new ApplicationPrivilegeDescriptor("app-1", "admin", Set.of("a:a:b:c", "a:x:y:z"), emptyMap()), + new ApplicationPrivilegeDescriptor("app-2", "read", Set.of("r:e:f:g", "r:t:u:v"), emptyMap()), + new ApplicationPrivilegeDescriptor("app-2", "write", Set.of("w:e:f:g", "w:t:u:v"), emptyMap()), + new ApplicationPrivilegeDescriptor("app-2", "admin", Set.of("a:e:f:g", "a:t:u:v"), emptyMap())); + + final PutPrivilegesRequest putPrivilegesRequest = new PutPrivilegesRequest(); + putPrivilegesRequest.setPrivileges(applicationPrivilegeDescriptors); + final ActionFuture future = + client().execute(PutPrivilegesAction.INSTANCE, putPrivilegesRequest); + + final PutPrivilegesResponse putPrivilegesResponse = future.actionGet(); + assertEquals(2, putPrivilegesResponse.created().size()); + assertEquals(6, putPrivilegesResponse.created().values().stream().mapToInt(List::size).sum()); + } + + public void testGetPrivilegesUsesCache() { + final Client client = client(); + + ApplicationPrivilegeDescriptor[] privileges = new GetPrivilegesRequestBuilder(client) + .application("app-2").privileges("write").execute().actionGet().privileges(); + + assertEquals(1, privileges.length); + assertEquals("app-2", privileges[0].getApplication()); + assertEquals("write", privileges[0].getName()); + + // A hacky way to test cache is populated and used by deleting the backing documents. + // The test will fail if the cache is not in place + assertFalse(client.prepareBulk() + .add(new DeleteRequest(SECURITY_MAIN_ALIAS, DOC_TYPE_VALUE + "_app-2:read")) + .add(new DeleteRequest(SECURITY_MAIN_ALIAS, DOC_TYPE_VALUE + "_app-2:write")) + .add(new DeleteRequest(SECURITY_MAIN_ALIAS, DOC_TYPE_VALUE + "_app-2:admin")) + .setRefreshPolicy(IMMEDIATE).execute().actionGet().hasFailures()); + + // We can still get the privileges because it is cached + privileges = new GetPrivilegesRequestBuilder(client) + .application("app-2").privileges("read").execute().actionGet().privileges(); + + assertEquals(1, privileges.length); + + // We can get all app-2 privileges because cache is keyed by application + privileges = new GetPrivilegesRequestBuilder(client) + .application("app-2").execute().actionGet().privileges(); + + assertEquals(3, privileges.length); + + // Now properly invalidate the cache + final ClearPrivilegesCacheResponse clearPrivilegesCacheResponse = + client.execute(ClearPrivilegesCacheAction.INSTANCE, new ClearPrivilegesCacheRequest()).actionGet(); + assertFalse(clearPrivilegesCacheResponse.hasFailures()); + + // app-2 is no longer found + privileges = new GetPrivilegesRequestBuilder(client) + .application("app-2").privileges("read").execute().actionGet().privileges(); + assertEquals(0, privileges.length); + } + + public void testPopulationOfCacheWhenLoadingPrivilegesForAllApplications() { + final Client client = client(); + + ApplicationPrivilegeDescriptor[] privileges = new GetPrivilegesRequestBuilder(client) + .execute().actionGet().privileges(); + + assertEquals(6, privileges.length); + + // Delete a privilege properly + deleteApplicationPrivilege("app-2", "read"); + + // A direct read should also get nothing + assertEquals(0, new GetPrivilegesRequestBuilder(client) + .application("app-2").privileges("read").execute().actionGet().privileges().length); + + // The wildcard expression expansion should be invalidated + assertEquals(5, new GetPrivilegesRequestBuilder(client).execute().actionGet().privileges().length); + + // Now put it back and wild expression expansion should be invalidated again + addApplicationPrivilege("app-2", "read", "r:e:f:g", "r:t:u:v"); + + assertEquals(6, new GetPrivilegesRequestBuilder(client).execute().actionGet().privileges().length); + + // Delete the privilege again which invalidate the wildcard expansion + deleteApplicationPrivilege("app-2", "read"); + + // The descriptors cache is keyed by application name hence removal of a app-2 privilege only affects + // app-2, but not app-1. The cache hit/miss is tested by removing the backing documents + assertFalse(client.prepareBulk() + .add(new DeleteRequest(SECURITY_MAIN_ALIAS, DOC_TYPE_VALUE + "_app-1:write")) + .add(new DeleteRequest(SECURITY_MAIN_ALIAS, DOC_TYPE_VALUE + "_app-2:write")) + .setRefreshPolicy(IMMEDIATE).execute().actionGet().hasFailures()); + + // app-2 write privilege will not be found since cache is invalidated and backing document is gone + assertEquals(0, new GetPrivilegesRequestBuilder(client) + .application("app-2").privileges("write").execute().actionGet().privileges().length); + + // app-1 write privilege is still found since it is cached even when the backing document is gone + assertEquals(1, new GetPrivilegesRequestBuilder(client) + .application("app-1").privileges("write").execute().actionGet().privileges().length); + } + + public void testSuffixWildcard() { + final Client client = client(); + + // Populate the cache with suffix wildcard + assertEquals(6, new GetPrivilegesRequestBuilder(client).application("app-*").execute().actionGet().privileges().length); + + // Delete a backing document + assertEquals(RestStatus.OK, client.prepareDelete(SECURITY_MAIN_ALIAS, DOC_TYPE_VALUE + "_app-1:read") + .setRefreshPolicy(IMMEDIATE).execute().actionGet().status()); + + // A direct get privilege with no wildcard should still hit the cache without needing it to be in the names cache + assertEquals(1, new GetPrivilegesRequestBuilder(client).application("app-1") + .privileges("read").execute().actionGet().privileges().length); + } + + public void testHasPrivileges() { + assertTrue(checkPrivilege("app-1", "read").getApplicationPrivileges() + .get("app-1").stream().findFirst().orElseThrow().getPrivileges().get("read")); + + assertFalse(checkPrivilege("app-1", "check").getApplicationPrivileges() + .get("app-1").stream().findFirst().orElseThrow().getPrivileges().get("check")); + + // Add the app-1 check privilege and it should be picked up + addApplicationPrivilege("app-1", "check", "c:a:b:c"); + assertTrue(checkPrivilege("app-1", "check").getApplicationPrivileges() + .get("app-1").stream().findFirst().orElseThrow().getPrivileges().get("check")); + + // Delete the app-1 read privilege and it should be picked up as well + deleteApplicationPrivilege("app-1", "read"); + assertFalse(checkPrivilege("app-1", "read").getApplicationPrivileges() + .get("app-1").stream().findFirst().orElseThrow().getPrivileges().get("read")); + + // TODO: This is a bug + assertTrue(checkPrivilege("app-2", "check").getApplicationPrivileges() + .get("app-2").stream().findFirst().orElseThrow().getPrivileges().get("check")); + } + + public void testRolesCacheIsClearedWhenPrivilegesIsChanged() { + final Client client = client(); + + // Add a new user and role so they do not interfere existing tests + final String testRole = "test_role_cache_role"; + final String testRoleCacheUser = "test_role_cache_user"; + final PutRoleResponse putRoleResponse = new PutRoleRequestBuilder(client).name(testRole). + cluster("all") + .addIndices(new String[] { "*" }, new String[] { "read" }, null, null, null, false) + .get(); + assertTrue(putRoleResponse.isCreated()); + + final PutUserResponse putUserResponse = new PutUserRequestBuilder(client) + .username(testRoleCacheUser) + .roles(testRole) + .password(new SecureString("password".toCharArray()), + Hasher.resolve(randomFrom("pbkdf2", "pbkdf2_1000", "bcrypt9", "bcrypt8", "bcrypt"))) + .get(); + assertTrue(putUserResponse.created()); + + // The created user can access cluster health because its role grants access + final Client testRoleCacheUserClient = client.filterWithHeader(singletonMap("Authorization", + "Basic " + Base64.getEncoder().encodeToString((testRoleCacheUser + ":password").getBytes(StandardCharsets.UTF_8)))); + new ClusterHealthRequestBuilder(testRoleCacheUserClient, ClusterHealthAction.INSTANCE).get(); + + // Directly deleted the role document + final DeleteResponse deleteResponse = client.prepareDelete(SECURITY_MAIN_ALIAS, "role-" + testRole).get(); + assertEquals(DocWriteResponse.Result.DELETED, deleteResponse.getResult()); + + // The cluster health action can still success since the role is cached + new ClusterHealthRequestBuilder(testRoleCacheUserClient, ClusterHealthAction.INSTANCE).get(); + + // Change an application privilege which triggers role cache invalidation as well + if (randomBoolean()) { + deleteApplicationPrivilege("app-1", "read"); + } else { + addApplicationPrivilege("app-3", "read", "r:q:r:s"); + } + // Since role cache is cleared, the cluster health action is no longer authorized + expectThrows(ElasticsearchSecurityException.class, + () -> new ClusterHealthRequestBuilder(testRoleCacheUserClient, ClusterHealthAction.INSTANCE).get()); + + } + + private HasPrivilegesResponse checkPrivilege(String applicationName, String privilegeName) { + final Client client = client().filterWithHeader(singletonMap("Authorization", + "Basic " + Base64.getEncoder().encodeToString(("app_user:" + TEST_PASSWORD).getBytes(StandardCharsets.UTF_8)))); + + // Has privileges always loads all privileges for an application + final HasPrivilegesRequest hasPrivilegesRequest = new HasPrivilegesRequest(); + hasPrivilegesRequest.username(APP_USER_NAME); + hasPrivilegesRequest.applicationPrivileges( + RoleDescriptor.ApplicationResourcePrivileges.builder() + .application(applicationName).privileges(privilegeName).resources("foo").build() + ); + hasPrivilegesRequest.clusterPrivileges("monitor"); + hasPrivilegesRequest.indexPrivileges(RoleDescriptor.IndicesPrivileges.builder().indices("*").privileges("read").build()); + return client.execute(HasPrivilegesAction.INSTANCE, hasPrivilegesRequest).actionGet(); + } + + private void addApplicationPrivilege(String applicationName, String privilegeName, String... actions) { + final List applicationPrivilegeDescriptors = Collections.singletonList( + new ApplicationPrivilegeDescriptor(applicationName, privilegeName, Set.of(actions), emptyMap())); + final PutPrivilegesRequest putPrivilegesRequest = new PutPrivilegesRequest(); + putPrivilegesRequest.setPrivileges(applicationPrivilegeDescriptors); + assertEquals(1, client().execute(PutPrivilegesAction.INSTANCE, putPrivilegesRequest).actionGet().created().keySet().size()); + } + + private void deleteApplicationPrivilege(String applicationName, String privilegeName) { + assertEquals(singleton(privilegeName), new DeletePrivilegesRequestBuilder(client()) + .application(applicationName).privileges(new String[] { privilegeName }).execute().actionGet().found()); + } +} diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/store/NativePrivilegeStoreTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/store/NativePrivilegeStoreTests.java index f0cfe173a083a..ab02a55e6df7c 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/store/NativePrivilegeStoreTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/store/NativePrivilegeStoreTests.java @@ -12,8 +12,6 @@ import org.elasticsearch.action.ActionType; import org.elasticsearch.action.delete.DeleteRequest; import org.elasticsearch.action.delete.DeleteResponse; -import org.elasticsearch.action.get.GetRequest; -import org.elasticsearch.action.get.GetResponse; import org.elasticsearch.action.index.IndexRequest; import org.elasticsearch.action.index.IndexResponse; import org.elasticsearch.action.search.SearchRequest; @@ -22,6 +20,8 @@ import org.elasticsearch.action.support.PlainActionFuture; import org.elasticsearch.action.support.WriteRequest; import org.elasticsearch.client.Client; +import org.elasticsearch.cluster.health.ClusterHealthStatus; +import org.elasticsearch.cluster.metadata.IndexMetadata; import org.elasticsearch.common.Strings; import org.elasticsearch.common.UUIDs; import org.elasticsearch.common.bytes.BytesArray; @@ -29,34 +29,42 @@ import org.elasticsearch.common.settings.Settings; import org.elasticsearch.common.xcontent.XContentBuilder; import org.elasticsearch.common.xcontent.XContentType; -import org.elasticsearch.index.get.GetResult; import org.elasticsearch.index.shard.ShardId; import org.elasticsearch.search.SearchHit; import org.elasticsearch.search.SearchHits; import org.elasticsearch.test.ESTestCase; import org.elasticsearch.test.client.NoOpClient; -import org.elasticsearch.xpack.core.security.action.role.ClearRolesCacheRequest; +import org.elasticsearch.xpack.core.security.action.privilege.ClearPrivilegesCacheRequest; import org.elasticsearch.xpack.core.security.authz.privilege.ApplicationPrivilegeDescriptor; import org.elasticsearch.xpack.core.security.index.RestrictedIndicesNames; import org.elasticsearch.xpack.security.support.SecurityIndexManager; -import org.hamcrest.Matchers; import org.junit.After; import org.junit.Before; import org.mockito.Mockito; +import java.time.Instant; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; +import java.util.Collections; import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; +import static java.util.Collections.emptyList; import static java.util.Collections.emptyMap; +import static java.util.Collections.emptySet; +import static java.util.Collections.singleton; +import static java.util.Collections.singletonList; import static org.elasticsearch.common.util.set.Sets.newHashSet; -import static org.elasticsearch.index.seqno.SequenceNumbers.UNASSIGNED_SEQ_NO; +import static org.hamcrest.Matchers.anyOf; import static org.hamcrest.Matchers.arrayContaining; import static org.hamcrest.Matchers.contains; import static org.hamcrest.Matchers.containsInAnyOrder; @@ -76,6 +84,7 @@ public class NativePrivilegeStoreTests extends ESTestCase { private List requests; private AtomicReference listener; private Client client; + private SecurityIndexManager securityIndex; @Before public void setup() { @@ -89,7 +98,7 @@ void doExecute(ActionType action, Request request, ActionListener sourcePrivileges = List.of( + new ApplicationPrivilegeDescriptor("myapp", "admin", + newHashSet("action:admin/*", "action:login", "data:read/*"), emptyMap() + )); - final PlainActionFuture future = new PlainActionFuture<>(); - store.getPrivilege("myapp", "admin", future); + final PlainActionFuture> future = new PlainActionFuture<>(); + store.getPrivileges(List.of("myapp"), List.of("admin"), future); assertThat(requests, iterableWithSize(1)); - assertThat(requests.get(0), instanceOf(GetRequest.class)); - GetRequest request = (GetRequest) requests.get(0); - assertThat(request.index(), equalTo(RestrictedIndicesNames.SECURITY_MAIN_ALIAS)); - assertThat(request.id(), equalTo("application-privilege_myapp:admin")); - - final String docSource = Strings.toString(sourcePrivilege); - listener.get().onResponse(new GetResponse( - new GetResult(request.index(), request.id(), 0, 1, 1L, true, - new BytesArray(docSource), emptyMap(), emptyMap()) - )); - final ApplicationPrivilegeDescriptor getPrivilege = future.get(1, TimeUnit.SECONDS); - assertThat(getPrivilege, equalTo(sourcePrivilege)); + assertThat(requests.get(0), instanceOf(SearchRequest.class)); + SearchRequest request = (SearchRequest) requests.get(0); + final String query = Strings.toString(request.source().query()); + assertThat(query, containsString("{\"terms\":{\"application\":[\"myapp\"]")); + assertThat(query, containsString("{\"term\":{\"type\":{\"value\":\"application-privilege\"")); + + final SearchHit[] hits = buildHits(sourcePrivileges); + listener.get().onResponse(new SearchResponse(new SearchResponseSections( + new SearchHits(hits, new TotalHits(hits.length, TotalHits.Relation.EQUAL_TO), 0f), + null, null, false, false, null, 1), + "_scrollId1", 1, 1, 0, 1, null, null)); + + assertResult(sourcePrivileges, future); } - public void testGetMissingPrivilege() throws Exception { - final PlainActionFuture future = new PlainActionFuture<>(); - store.getPrivilege("myapp", "admin", future); - assertThat(requests, iterableWithSize(1)); - assertThat(requests.get(0), instanceOf(GetRequest.class)); - GetRequest request = (GetRequest) requests.get(0); - assertThat(request.index(), equalTo(RestrictedIndicesNames.SECURITY_MAIN_ALIAS)); - assertThat(request.id(), equalTo("application-privilege_myapp:admin")); - - listener.get().onResponse(new GetResponse( - new GetResult(request.index(), request.id(), UNASSIGNED_SEQ_NO, 0, -1, - false, null, emptyMap(), emptyMap()) - )); - final ApplicationPrivilegeDescriptor getPrivilege = future.get(1, TimeUnit.SECONDS); - assertThat(getPrivilege, Matchers.nullValue()); + public void testGetMissingPrivilege() throws InterruptedException, ExecutionException, TimeoutException { + final PlainActionFuture> future = new PlainActionFuture<>(); + store.getPrivileges(List.of("myapp"), List.of("admin"), future); + final SearchHit[] hits = new SearchHit[0]; + listener.get().onResponse(new SearchResponse(new SearchResponseSections( + new SearchHits(hits, new TotalHits(hits.length, TotalHits.Relation.EQUAL_TO), 0f), + null, null, false, false, null, 1), + "_scrollId1", 1, 1, 0, 1, null, null)); + + final Collection applicationPrivilegeDescriptors = future.get(1, TimeUnit.SECONDS); + assertThat(applicationPrivilegeDescriptors.size(), equalTo(0)); } public void testGetPrivilegesByApplicationName() throws Exception { @@ -167,7 +174,9 @@ public void testGetPrivilegesByApplicationName() throws Exception { assertThat(request.indices(), arrayContaining(RestrictedIndicesNames.SECURITY_MAIN_ALIAS)); final String query = Strings.toString(request.source().query()); - assertThat(query, containsString("{\"terms\":{\"application\":[\"myapp\",\"yourapp\"]")); + assertThat(query, anyOf( + containsString("{\"terms\":{\"application\":[\"myapp\",\"yourapp\"]"), + containsString("{\"terms\":{\"application\":[\"yourapp\",\"myapp\"]"))); assertThat(query, containsString("{\"term\":{\"type\":{\"value\":\"application-privilege\"")); final SearchHit[] hits = buildHits(sourcePrivileges); @@ -245,6 +254,195 @@ public void testGetAllPrivileges() throws Exception { assertResult(sourcePrivileges, future); } + public void testGetPrivilegesCacheByApplicationNames() throws Exception { + final List sourcePrivileges = Arrays.asList( + new ApplicationPrivilegeDescriptor("myapp", "admin", newHashSet("action:admin/*", "action:login", "data:read/*"), emptyMap()), + new ApplicationPrivilegeDescriptor("myapp", "user", newHashSet("action:login", "data:read/*"), emptyMap()), + new ApplicationPrivilegeDescriptor("myapp", "author", newHashSet("action:login", "data:read/*", "data:write/*"), emptyMap()) + ); + + final PlainActionFuture> future = new PlainActionFuture<>(); + store.getPrivileges(List.of("myapp", "yourapp"), null, future); + + final SearchHit[] hits = buildHits(sourcePrivileges); + listener.get().onResponse(new SearchResponse(new SearchResponseSections( + new SearchHits(hits, new TotalHits(hits.length, TotalHits.Relation.EQUAL_TO), 0f), + null, null, false, false, null, 1), + "_scrollId1", 1, 1, 0, 1, null, null)); + + assertEquals(Set.of("myapp"), store.getApplicationNamesCache().get(Set.of("myapp", "yourapp"))); + assertEquals(Set.copyOf(sourcePrivileges), store.getDescriptorsCache().get("myapp")); + assertResult(sourcePrivileges, future); + + // The 2nd call should use cache and success + final PlainActionFuture> future2 = new PlainActionFuture<>(); + store.getPrivileges(List.of("myapp", "yourapp"), null, future2); + listener.get().onResponse(null); + assertResult(sourcePrivileges, future2); + + // The 3rd call should use cache when the application name is part of the original query + final PlainActionFuture> future3 = new PlainActionFuture<>(); + store.getPrivileges(List.of("myapp"), null, future3); + listener.get().onResponse(null); + // Does not cache the name expansion if descriptors of the literal name is already cached + assertNull(store.getApplicationNamesCache().get(Set.of("myapp"))); + assertResult(sourcePrivileges, future3); + } + + public void testGetPrivilegesCacheWithApplicationAndPrivilegeName() throws Exception { + final List sourcePrivileges = List.of( + new ApplicationPrivilegeDescriptor("myapp", "admin", newHashSet("action:admin/*", "action:login", "data:read/*"), emptyMap()), + new ApplicationPrivilegeDescriptor("myapp", "user", newHashSet("action:login", "data:read/*"), emptyMap()), + new ApplicationPrivilegeDescriptor("myapp", "author", newHashSet("action:login", "data:read/*", "data:write/*"), emptyMap()) + ); + + final PlainActionFuture> future = new PlainActionFuture<>(); + store.getPrivileges(Collections.singletonList("myapp"), singletonList("user"), future); + + final SearchHit[] hits = buildHits(sourcePrivileges); + listener.get().onResponse(new SearchResponse(new SearchResponseSections( + new SearchHits(hits, new TotalHits(hits.length, TotalHits.Relation.EQUAL_TO), 0f), + null, null, false, false, null, 1), + "_scrollId1", 1, 1, 0, 1, null, null)); + + // Not caching names with no wildcard + assertNull(store.getApplicationNamesCache().get(singleton("myapp"))); + // All privileges are cached + assertEquals(Set.copyOf(sourcePrivileges), store.getDescriptorsCache().get("myapp")); + assertResult(sourcePrivileges.subList(1, 2), future); + + // 2nd call with more privilege names can still use the cache + final PlainActionFuture> future2 = new PlainActionFuture<>(); + store.getPrivileges(Collections.singletonList("myapp"), List.of("user", "author"), future2); + listener.get().onResponse(null); + assertResult(sourcePrivileges.subList(1, 3), future2); + } + + public void testGetPrivilegesCacheWithNonExistentApplicationName() throws Exception { + final PlainActionFuture> future = new PlainActionFuture<>(); + store.getPrivileges(Collections.singletonList("no-such-app"), null, future); + final SearchHit[] hits = buildHits(emptyList()); + listener.get().onResponse(new SearchResponse(new SearchResponseSections( + new SearchHits(hits, new TotalHits(hits.length, TotalHits.Relation.EQUAL_TO), 0f), + null, null, false, false, null, 1), + "_scrollId1", 1, 1, 0, 1, null, null) ); + + assertEquals(emptySet(), store.getApplicationNamesCache().get(singleton("no-such-app"))); + assertEquals(0, store.getDescriptorsCache().count()); + assertResult(emptyList(), future); + + // The 2nd call should use cache + final PlainActionFuture> future2 = new PlainActionFuture<>(); + store.getPrivileges(Collections.singletonList("no-such-app"), null, future2); + listener.get().onResponse(null); + assertResult(emptyList(), future2); + } + + public void testGetPrivilegesCacheWithDifferentMatchAllApplicationNames() throws Exception { + final PlainActionFuture> future = new PlainActionFuture<>(); + store.getPrivileges(emptyList(), null, future); + final SearchHit[] hits = buildHits(emptyList()); + listener.get().onResponse(new SearchResponse(new SearchResponseSections( + new SearchHits(hits, new TotalHits(hits.length, TotalHits.Relation.EQUAL_TO), 0f), + null, null, false, false, null, 1), + "_scrollId1", 1, 1, 0, 1, null, null) ); + assertEquals(emptySet(), store.getApplicationNamesCache().get(singleton("*"))); + assertEquals(1, store.getApplicationNamesCache().count()); + assertResult(emptyList(), future); + + // The 2nd call should use cache should translated to match all since it has a "*" + final PlainActionFuture> future2 = new PlainActionFuture<>(); + store.getPrivileges(List.of("a", "b", "*", "c"), null, future2); + assertEquals(emptySet(), store.getApplicationNamesCache().get(singleton("*"))); + assertEquals(1, store.getApplicationNamesCache().count()); + assertResult(emptyList(), future2); + + // The 3rd call also translated to match all + final PlainActionFuture> future3 = new PlainActionFuture<>(); + store.getPrivileges(null, null, future3); + assertEquals(emptySet(), store.getApplicationNamesCache().get(singleton("*"))); + assertEquals(1, store.getApplicationNamesCache().count()); + assertResult(emptyList(), future3); + + // The 4th call is also match all + final PlainActionFuture> future4 = new PlainActionFuture<>(); + store.getPrivileges(List.of("*"), null, future4); + assertEquals(emptySet(), store.getApplicationNamesCache().get(singleton("*"))); + assertEquals(1, store.getApplicationNamesCache().count()); + assertResult(emptyList(), future4); + } + + public void testStaleResultsWillNotBeCached() { + final List sourcePrivileges = singletonList( + new ApplicationPrivilegeDescriptor("myapp", "admin", newHashSet("action:admin/*", "action:login", "data:read/*"), emptyMap()) + ); + + final PlainActionFuture> future = new PlainActionFuture<>(); + store.getPrivileges(null, null, future); + + // Before the results can be cached, invalidate the cache to simulate stale search results + store.invalidateAll(); + final SearchHit[] hits = buildHits(sourcePrivileges); + listener.get().onResponse(new SearchResponse(new SearchResponseSections( + new SearchHits(hits, new TotalHits(hits.length, TotalHits.Relation.EQUAL_TO), 0f), + null, null, false, false, null, 1), + "_scrollId1", 1, 1, 0, 1, null, null)); + + // Nothing should be cached since the results are stale + assertEquals(0, store.getApplicationNamesCache().count()); + assertEquals(0, store.getDescriptorsCache().count()); + } + + public void testWhenStaleResultsAreCachedTheyWillBeCleared() throws InterruptedException { + final List sourcePrivileges = singletonList( + new ApplicationPrivilegeDescriptor("myapp", "admin", newHashSet("action:admin/*", "action:login", "data:read/*"), emptyMap()) + ); + + final CountDownLatch getPrivilegeCountDown = new CountDownLatch(1); + final CountDownLatch invalidationCountDown = new CountDownLatch(1); + // Use subclass so we can put the caching process on hold, which allows time to fire the cache invalidation call + // When the process reaches the overridden method, it already acquires the read lock. + // Hence the cache invalidation will be block at acquiring the write lock. + // This simulates the scenario when stale results are cached just before the invalidation call arrives. + // In this case, we guarantee the cache will be invalidate and the stale results won't stay for long. + final NativePrivilegeStore store1 = new NativePrivilegeStore(Settings.EMPTY, client, securityIndex) { + @Override + protected void cacheFetchedDescriptors( + Set applicationNamesCacheKey, Map> mapOfFetchedDescriptors) { + getPrivilegeCountDown.countDown(); + try { + // wait till the invalidation call is at the door step + invalidationCountDown.await(5, TimeUnit.SECONDS); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + super.cacheFetchedDescriptors(applicationNamesCacheKey, mapOfFetchedDescriptors); + // Assert that cache is successful + assertEquals(1, getApplicationNamesCache().count()); + assertEquals(1, getDescriptorsCache().count()); + } + }; + final PlainActionFuture> future = new PlainActionFuture<>(); + store1.getPrivileges(null, null, future); + final SearchHit[] hits = buildHits(sourcePrivileges); + listener.get().onResponse(new SearchResponse(new SearchResponseSections( + new SearchHits(hits, new TotalHits(hits.length, TotalHits.Relation.EQUAL_TO), 0f), + null, null, false, false, null, 1), + "_scrollId1", 1, 1, 0, 1, null, null)); + + // Make sure the caching is about to happen + getPrivilegeCountDown.await(5, TimeUnit.SECONDS); + // Fire the invalidation call in another thread + new Thread(() -> { + // Let the caching proceed + invalidationCountDown.countDown(); + store.invalidateAll(); + }).start(); + // The cache should be cleared + assertEquals(0, store.getApplicationNamesCache().count()); + assertEquals(0, store.getDescriptorsCache().count()); + } + public void testPutPrivileges() throws Exception { final List putPrivileges = Arrays.asList( new ApplicationPrivilegeDescriptor("app1", "admin", newHashSet("action:admin/*", "action:login", "data:read/*"), emptyMap()), @@ -252,8 +450,8 @@ public void testPutPrivileges() throws Exception { new ApplicationPrivilegeDescriptor("app2", "all", newHashSet("*"), emptyMap()) ); - final PlainActionFuture>> future = new PlainActionFuture<>(); - store.putPrivileges(putPrivileges, WriteRequest.RefreshPolicy.IMMEDIATE, future); + final PlainActionFuture>> putPrivilegeFuture = new PlainActionFuture<>(); + store.putPrivileges(putPrivileges, WriteRequest.RefreshPolicy.IMMEDIATE, putPrivilegeFuture); assertThat(requests, iterableWithSize(putPrivileges.size())); assertThat(requests, everyItem(instanceOf(IndexRequest.class))); @@ -282,10 +480,10 @@ public void testPutPrivileges() throws Exception { assertBusy(() -> assertFalse(requests.isEmpty()), 1, TimeUnit.SECONDS); assertThat(requests, iterableWithSize(1)); - assertThat(requests.get(0), instanceOf(ClearRolesCacheRequest.class)); + assertThat(requests.get(0), instanceOf(ClearPrivilegesCacheRequest.class)); listener.get().onResponse(null); - final Map> map = future.actionGet(); + final Map> map = putPrivilegeFuture.actionGet(); assertThat(map.entrySet(), iterableWithSize(2)); assertThat(map.get("app1"), iterableWithSize(1)); assertThat(map.get("app2"), iterableWithSize(1)); @@ -322,7 +520,7 @@ public void testDeletePrivileges() throws Exception { assertBusy(() -> assertFalse(requests.isEmpty()), 1, TimeUnit.SECONDS); assertThat(requests, iterableWithSize(1)); - assertThat(requests.get(0), instanceOf(ClearRolesCacheRequest.class)); + assertThat(requests.get(0), instanceOf(ClearPrivilegesCacheRequest.class)); listener.get().onResponse(null); final Map> map = future.actionGet(); @@ -331,6 +529,90 @@ public void testDeletePrivileges() throws Exception { assertThat(map.get("app1"), containsInAnyOrder("p1", "p3")); } + public void testInvalidate() { + store.getApplicationNamesCache().put(singleton("*"), Set.of()); + store.getDescriptorsCache().put("app-1", + singleton(new ApplicationPrivilegeDescriptor("app-1", "read", emptySet(), emptyMap()))); + store.getDescriptorsCache().put("app-2", + singleton(new ApplicationPrivilegeDescriptor("app-2", "read", emptySet(), emptyMap()))); + store.invalidate(singletonList("app-1")); + assertEquals(0, store.getApplicationNamesCache().count()); + assertEquals(1, store.getDescriptorsCache().count()); + } + + public void testInvalidateAll() { + store.getApplicationNamesCache().put(singleton("*"), Set.of()); + store.getDescriptorsCache().put("app-1", + singleton(new ApplicationPrivilegeDescriptor("app-1", "read", emptySet(), emptyMap()))); + store.getDescriptorsCache().put("app-2", + singleton(new ApplicationPrivilegeDescriptor("app-2", "read", emptySet(), emptyMap()))); + store.invalidateAll(); + assertEquals(0, store.getApplicationNamesCache().count()); + assertEquals(0, store.getDescriptorsCache().count()); + } + + public void testCacheClearOnIndexHealthChange() { + final String securityIndexName = randomFrom( + RestrictedIndicesNames.INTERNAL_SECURITY_MAIN_INDEX_6, RestrictedIndicesNames.INTERNAL_SECURITY_MAIN_INDEX_7); + + long count = store.getNumInvalidation().get(); + + // Cache should be cleared when security is back to green + store.onSecurityIndexStateChange( + dummyState(securityIndexName, true, randomFrom((ClusterHealthStatus) null, ClusterHealthStatus.RED)), + dummyState(securityIndexName, true, randomFrom(ClusterHealthStatus.GREEN, ClusterHealthStatus.YELLOW))); + assertEquals(++count, store.getNumInvalidation().get()); + + // Cache should be cleared when security is deleted + store.onSecurityIndexStateChange( + dummyState(securityIndexName, true, randomFrom(ClusterHealthStatus.values())), + dummyState(securityIndexName, true, null)); + assertEquals(++count, store.getNumInvalidation().get()); + + // Cache should be cleared if indexUpToDate changed + final boolean isIndexUpToDate = randomBoolean(); + final ArrayList allPossibleHealthStatus = new ArrayList<>(Arrays.asList(ClusterHealthStatus.values())); + allPossibleHealthStatus.add(null); + store.onSecurityIndexStateChange( + dummyState(securityIndexName, isIndexUpToDate, randomFrom(allPossibleHealthStatus)), + dummyState(securityIndexName, !isIndexUpToDate, randomFrom(allPossibleHealthStatus))); + assertEquals(++count, store.getNumInvalidation().get()); + } + + public void testCacheWillBeDisabledWhenTtlIsZero() { + final Settings settings = Settings.builder().put("xpack.security.authz.store.privileges.cache.ttl", 0).build(); + final NativePrivilegeStore store1 = new NativePrivilegeStore(settings, client, securityIndex); + assertNull(store1.getApplicationNamesCache()); + assertNull(store1.getDescriptorsCache()); + } + public void testGetPrivilegesWorkWithoutCache() throws Exception { + final Settings settings = Settings.builder().put("xpack.security.authz.store.privileges.cache.ttl", 0).build(); + final NativePrivilegeStore store1 = new NativePrivilegeStore(settings, client, securityIndex); + final List sourcePrivileges = Arrays.asList( + new ApplicationPrivilegeDescriptor("myapp", "admin", newHashSet("action:admin/*", "action:login", "data:read/*"), emptyMap()) + ); + final PlainActionFuture> future = new PlainActionFuture<>(); + store1.getPrivileges(singletonList("myapp"), null, future); + final SearchHit[] hits = buildHits(sourcePrivileges); + listener.get().onResponse(new SearchResponse(new SearchResponseSections( + new SearchHits(hits, new TotalHits(hits.length, TotalHits.Relation.EQUAL_TO), 0f), + null, null, false, false, null, 1), + "_scrollId1", 1, 1, 0, 1, null, null)); + + assertResult(sourcePrivileges, future); + // They are no-op but should "work" (pass-through) + store1.invalidate(singleton("myapp")); + store1.invalidateAll(); + } + + private SecurityIndexManager.State dummyState( + String concreteSecurityIndexName, boolean isIndexUpToDate, ClusterHealthStatus healthStatus) { + return new SecurityIndexManager.State( + Instant.now(), isIndexUpToDate, true, true, null, + concreteSecurityIndexName, healthStatus, IndexMetadata.State.OPEN + ); + } + private SearchHit[] buildHits(List sourcePrivileges) { final SearchHit[] hits = new SearchHit[sourcePrivileges.size()]; for (int i = 0; i < hits.length; i++) { diff --git a/x-pack/plugin/src/test/resources/rest-api-spec/api/security.clear_cached_privileges.json b/x-pack/plugin/src/test/resources/rest-api-spec/api/security.clear_cached_privileges.json new file mode 100644 index 0000000000000..e2c66a32fa27b --- /dev/null +++ b/x-pack/plugin/src/test/resources/rest-api-spec/api/security.clear_cached_privileges.json @@ -0,0 +1,26 @@ +{ + "security.clear_cached_privileges":{ + "documentation":{ + "url":"https://www.elastic.co/guide/en/elasticsearch/reference/current/security-api-clear-privilege-cache.html", + "description":"Evicts application privileges from the native application privileges cache." + }, + "stability":"stable", + "url":{ + "paths":[ + { + "path":"/_security/privilege/{application}/_clear_cache", + "methods":[ + "POST" + ], + "parts":{ + "application":{ + "type":"list", + "description":"A comma-separated list of application names" + } + } + } + ] + }, + "params":{} + } +} diff --git a/x-pack/plugin/src/test/resources/rest-api-spec/test/privileges/10_basic.yml b/x-pack/plugin/src/test/resources/rest-api-spec/test/privileges/10_basic.yml index e003dba2c2185..572cf2fd48f7a 100644 --- a/x-pack/plugin/src/test/resources/rest-api-spec/test/privileges/10_basic.yml +++ b/x-pack/plugin/src/test/resources/rest-api-spec/test/privileges/10_basic.yml @@ -324,3 +324,19 @@ teardown: "metadata": { } } } + +--- +"Test clear privileges cache": + - skip: + version: " - 7.99.99" + reason: "backport pending https://github.com/elastic/elasticsearch/pull/55836" + + - do: + security.clear_cached_privileges: + application: "app" + - match: { _nodes.failed: 0 } + + - do: + security.clear_cached_privileges: + application: "" + - match: { _nodes.failed: 0 }