diff --git a/docs/changelog/135271.yaml b/docs/changelog/135271.yaml new file mode 100644 index 0000000000000..be9e2ad30f351 --- /dev/null +++ b/docs/changelog/135271.yaml @@ -0,0 +1,5 @@ +pr: 135271 +summary: Add DLS stats to `_security/stats` +area: Authorization +type: enhancement +issues: [] diff --git a/server/src/main/resources/transport/definitions/referable/roles_security_stats.csv b/server/src/main/resources/transport/definitions/referable/roles_security_stats.csv new file mode 100644 index 0000000000000..958f4e9c30d79 --- /dev/null +++ b/server/src/main/resources/transport/definitions/referable/roles_security_stats.csv @@ -0,0 +1 @@ +9176000 diff --git a/server/src/main/resources/transport/upper_bounds/9.2.csv b/server/src/main/resources/transport/upper_bounds/9.2.csv index 455c9526b5cfd..78180d915cd67 100644 --- a/server/src/main/resources/transport/upper_bounds/9.2.csv +++ b/server/src/main/resources/transport/upper_bounds/9.2.csv @@ -1 +1 @@ -contextual_ai_service,9175000 +roles_security_stats,9176000 diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/stats/GetSecurityStatsNodeResponse.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/stats/GetSecurityStatsNodeResponse.java index 7956ec2eabf87..3834f503caad6 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/stats/GetSecurityStatsNodeResponse.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/stats/GetSecurityStatsNodeResponse.java @@ -7,32 +7,74 @@ package org.elasticsearch.xpack.core.security.action.stats; +import org.elasticsearch.TransportVersion; import org.elasticsearch.action.support.nodes.BaseNodeResponse; import org.elasticsearch.cluster.node.DiscoveryNode; import org.elasticsearch.common.io.stream.StreamInput; import org.elasticsearch.common.io.stream.StreamOutput; +import org.elasticsearch.core.Nullable; import org.elasticsearch.xcontent.ToXContentObject; import org.elasticsearch.xcontent.XContentBuilder; import java.io.IOException; +import java.util.Collections; +import java.util.Map; +import java.util.Objects; public class GetSecurityStatsNodeResponse extends BaseNodeResponse implements ToXContentObject { + private static final TransportVersion ROLES_SECURITY_STATS = TransportVersion.fromName("roles_security_stats"); + + @Nullable + private final Map rolesStoreStats; + public GetSecurityStatsNodeResponse(final StreamInput in) throws IOException { super(in); + this.rolesStoreStats = in.getTransportVersion().supports(ROLES_SECURITY_STATS) ? in.readGenericMap() : null; } - public GetSecurityStatsNodeResponse(final DiscoveryNode node) { + public GetSecurityStatsNodeResponse(final DiscoveryNode node, @Nullable final Map rolesStoreStats) { super(node); + this.rolesStoreStats = rolesStoreStats; } @Override public void writeTo(final StreamOutput out) throws IOException { super.writeTo(out); + if (out.getTransportVersion().supports(ROLES_SECURITY_STATS)) { + out.writeGenericMap(rolesStoreStats); + } } @Override public XContentBuilder toXContent(final XContentBuilder builder, final Params params) throws IOException { + if (rolesStoreStats != null) { + builder.field("roles", rolesStoreStats); + } return builder; } + + @Override + public boolean equals(Object o) { + return this == o + || (o instanceof GetSecurityStatsNodeResponse that + && Objects.equals(getNode(), that.getNode()) + && Objects.equals(rolesStoreStats, that.rolesStoreStats)); + } + + @Override + public int hashCode() { + return Objects.hash(getNode(), rolesStoreStats); + } + + // for testing + @Nullable + Map getRolesStoreStats() { + return rolesStoreStats == null ? null : Collections.unmodifiableMap(rolesStoreStats); + } + + // for testing + DiscoveryNode getDiscoveryNode() { + return getNode(); + } } diff --git a/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/stats/GetSecurityStatsNodeResponseTests.java b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/stats/GetSecurityStatsNodeResponseTests.java new file mode 100644 index 0000000000000..f1cc7b91cb702 --- /dev/null +++ b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/stats/GetSecurityStatsNodeResponseTests.java @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.core.security.action.stats; + +import org.elasticsearch.cluster.node.DiscoveryNodeUtils; +import org.elasticsearch.common.io.stream.Writeable; +import org.elasticsearch.test.AbstractWireSerializingTestCase; + +import java.io.IOException; +import java.util.Map; +import java.util.Objects; + +public class GetSecurityStatsNodeResponseTests extends AbstractWireSerializingTestCase { + + @Override + protected Writeable.Reader instanceReader() { + return GetSecurityStatsNodeResponse::new; + } + + @Override + protected GetSecurityStatsNodeResponse createTestInstance() { + return new GetSecurityStatsNodeResponse( + DiscoveryNodeUtils.builder(randomAlphaOfLength(10)).ephemeralId(randomAlphanumericOfLength(10)).build(), + randomBoolean() ? null : Map.of("key", randomAlphaOfLength(5)) + ); + } + + @Override + protected GetSecurityStatsNodeResponse mutateInstance(GetSecurityStatsNodeResponse instance) throws IOException { + final var node = instance.getDiscoveryNode(); + final var value = Objects.requireNonNullElse(instance.getRolesStoreStats(), Map.of()).get("key"); + return switch (randomIntBetween(0, 1)) { + case 0 -> new GetSecurityStatsNodeResponse( + DiscoveryNodeUtils.builder(randomValueOtherThan(node.getId(), () -> randomAlphaOfLength(10))) + // DiscoverNode#hashCode only tests ephemeralId, so make sure to change it too + .ephemeralId(randomValueOtherThan(node.getEphemeralId(), () -> randomAlphanumericOfLength(10))) + .build(), + instance.getRolesStoreStats() + ); + case 1 -> new GetSecurityStatsNodeResponse(node, Map.of("key", randomValueOtherThan(value, () -> randomAlphaOfLength(5)))); + default -> throw new IllegalStateException("Unexpected value"); + }; + } +} diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/stats/TransportSecurityStatsAction.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/stats/TransportSecurityStatsAction.java index 96cd95a800c3c..5ded7055c048a 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/stats/TransportSecurityStatsAction.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/stats/TransportSecurityStatsAction.java @@ -12,6 +12,7 @@ import org.elasticsearch.cluster.node.DiscoveryNode; import org.elasticsearch.cluster.service.ClusterService; import org.elasticsearch.common.io.stream.StreamInput; +import org.elasticsearch.core.Nullable; import org.elasticsearch.injection.guice.Inject; import org.elasticsearch.tasks.Task; import org.elasticsearch.threadpool.ThreadPool; @@ -21,6 +22,7 @@ import org.elasticsearch.xpack.core.security.action.stats.GetSecurityStatsNodeResponse; import org.elasticsearch.xpack.core.security.action.stats.GetSecurityStatsNodesRequest; import org.elasticsearch.xpack.core.security.action.stats.GetSecurityStatsNodesResponse; +import org.elasticsearch.xpack.security.authz.store.CompositeRolesStore; import java.io.IOException; import java.util.List; @@ -32,12 +34,16 @@ public class TransportSecurityStatsAction extends TransportNodesAction< GetSecurityStatsNodeResponse, Void> { + @Nullable + private final CompositeRolesStore rolesStore; + @Inject public TransportSecurityStatsAction( ThreadPool threadPool, ClusterService clusterService, TransportService transportService, - ActionFilters actionFilters + ActionFilters actionFilters, + CompositeRolesStore rolesStore ) { super( GetSecurityStatsAction.INSTANCE.name(), @@ -47,6 +53,7 @@ public TransportSecurityStatsAction( GetSecurityStatsNodeRequest::new, threadPool.executor(ThreadPool.Names.MANAGEMENT) ); + this.rolesStore = rolesStore; } @Override @@ -70,6 +77,6 @@ protected GetSecurityStatsNodeResponse newNodeResponse(final StreamInput in, fin @Override protected GetSecurityStatsNodeResponse nodeOperation(final GetSecurityStatsNodeRequest request, final Task task) { - return new GetSecurityStatsNodeResponse(clusterService.localNode()); + return new GetSecurityStatsNodeResponse(clusterService.localNode(), rolesStore == null ? null : rolesStore.usageStatsWithJustDls()); } } diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/store/CompositeRolesStore.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/store/CompositeRolesStore.java index be181af4fcd86..9304394297e52 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/store/CompositeRolesStore.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/store/CompositeRolesStore.java @@ -669,6 +669,10 @@ Iterable> cachedRoles() { return this.roleCache.keys(); } + public Map usageStatsWithJustDls() { + return Map.of("dls", Map.of("bit_set_cache", dlsBitsetCache.usageStats())); + } + public void usageStats(ActionListener> listener) { final Map usage = new HashMap<>(); usage.put("dls", Map.of("bit_set_cache", dlsBitsetCache.usageStats())); diff --git a/x-pack/plugin/src/yamlRestTest/resources/rest-api-spec/test/security/stats/10_skeleton.yml b/x-pack/plugin/src/yamlRestTest/resources/rest-api-spec/test/security/stats/10_skeleton.yml index 512e7c1f6e474..82ccee6b07a0b 100644 --- a/x-pack/plugin/src/yamlRestTest/resources/rest-api-spec/test/security/stats/10_skeleton.yml +++ b/x-pack/plugin/src/yamlRestTest/resources/rest-api-spec/test/security/stats/10_skeleton.yml @@ -1,5 +1,5 @@ --- -"Security stats returns empty response": +"Security stats returns a number of stats": - requires: cluster_features: [ "security_stats_endpoint" ] reason: Introduced in 9.2 @@ -9,4 +9,4 @@ - set: nodes._arbitrary_key_: node_id - - length: { nodes.$node_id: 0 } + - length: { nodes.$node_id: 1 } diff --git a/x-pack/plugin/src/yamlRestTest/resources/rest-api-spec/test/security/stats/20_roles_stats.yml b/x-pack/plugin/src/yamlRestTest/resources/rest-api-spec/test/security/stats/20_roles_stats.yml new file mode 100644 index 0000000000000..e6901582630f8 --- /dev/null +++ b/x-pack/plugin/src/yamlRestTest/resources/rest-api-spec/test/security/stats/20_roles_stats.yml @@ -0,0 +1,16 @@ +--- +"Security stats return just DLS stats": + - requires: + cluster_features: [ "security_stats_endpoint" ] + reason: Introduced in 9.2 + + - do: + security.get_stats: {} + + - set: + nodes._arbitrary_key_: node_id + - length: { nodes.$node_id: 1 } + - length: { nodes.$node_id.roles: 1 } + - length: { nodes.$node_id.roles.dls: 1 } + - length: { nodes.$node_id.roles.dls.bit_set_cache: 8 } + - gte: { nodes.$node_id.roles.dls.bit_set_cache.count: 0 }