Skip to content

Commit

Permalink
Add usage stats report for user profiles (#90123)
Browse files Browse the repository at this point in the history
This PR augments the existing xpack usage API to report total, enabled
and recent (last 30 days) usage stats of user profiles.
  • Loading branch information
ywangd committed Sep 20, 2022
1 parent b1acb36 commit bfda66a
Show file tree
Hide file tree
Showing 10 changed files with 225 additions and 10 deletions.
5 changes: 5 additions & 0 deletions docs/changelog/90123.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
pr: 90123
summary: Add usage stats report for user profiles
area: Security
type: enhancement
issues: []
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ public class SecurityFeatureSetUsage extends XPackFeatureSet.Usage {
private static final String FIPS_140_XFIELD = "fips_140";
private static final String OPERATOR_PRIVILEGES_XFIELD = XPackField.OPERATOR_PRIVILEGES;
private static final String DOMAINS_XFIELD = "domains";
private static final String USER_PROFILE_XFIELD = "user_profile";

private Map<String, Object> realmsUsage;
private Map<String, Object> rolesStoreUsage;
Expand All @@ -44,6 +45,7 @@ public class SecurityFeatureSetUsage extends XPackFeatureSet.Usage {
private Map<String, Object> fips140Usage;
private Map<String, Object> operatorPrivilegesUsage;
private Map<String, Object> domainsUsage;
private Map<String, Object> userProfileUsage;

public SecurityFeatureSetUsage(StreamInput in) throws IOException {
super(in);
Expand All @@ -67,6 +69,9 @@ public SecurityFeatureSetUsage(StreamInput in) throws IOException {
if (in.getVersion().onOrAfter(Version.V_8_2_0)) {
domainsUsage = in.readMap();
}
if (in.getVersion().onOrAfter(Version.V_8_5_0)) {
userProfileUsage = in.readMap();
}
}

public SecurityFeatureSetUsage(
Expand All @@ -82,7 +87,8 @@ public SecurityFeatureSetUsage(
Map<String, Object> apiKeyServiceUsage,
Map<String, Object> fips140Usage,
Map<String, Object> operatorPrivilegesUsage,
Map<String, Object> domainsUsage
Map<String, Object> domainsUsage,
Map<String, Object> userProfileUsage
) {
super(XPackField.SECURITY, true, enabled);
this.realmsUsage = realmsUsage;
Expand All @@ -97,6 +103,7 @@ public SecurityFeatureSetUsage(
this.fips140Usage = fips140Usage;
this.operatorPrivilegesUsage = operatorPrivilegesUsage;
this.domainsUsage = domainsUsage;
this.userProfileUsage = userProfileUsage;
}

@Override
Expand Down Expand Up @@ -127,6 +134,9 @@ public void writeTo(StreamOutput out) throws IOException {
if (out.getVersion().onOrAfter(Version.V_8_2_0)) {
out.writeGenericMap(domainsUsage);
}
if (out.getVersion().onOrAfter(Version.V_8_5_0)) {
out.writeGenericMap(userProfileUsage);
}
}

@Override
Expand All @@ -147,6 +157,9 @@ protected void innerXContent(XContentBuilder builder, Params params) throws IOEx
if (domainsUsage != null && false == domainsUsage.isEmpty()) {
builder.field(DOMAINS_XFIELD, domainsUsage);
}
if (userProfileUsage != null && false == userProfileUsage.isEmpty()) {
builder.field(USER_PROFILE_XFIELD, userProfileUsage);
}
} else if (sslUsage.isEmpty() == false) {
// A trial (or basic) license can have SSL without security.
// This is because security defaults to disabled on that license, but that dynamic-default does not disable SSL.
Expand Down
2 changes: 1 addition & 1 deletion x-pack/plugin/security/qa/profile/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,6 @@ testClusters.matching { it.name == 'javaRestTest' }.configureEach {
// Ensure new cache setting is recognised
setting 'xpack.security.authz.store.roles.has_privileges.cache.max_size', '100'

user username: "test_admin", password: 'x-pack-test-password'
user username: "test-admin", password: 'x-pack-test-password'
user username: "rac-user", password: 'x-pack-test-password', role: "rac_user_role"
}
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ protected Settings restAdminSettings() {
return Settings.builder()
.put(
ThreadContext.PREFIX + ".Authorization",
basicAuthHeaderValue("test_admin", new SecureString("x-pack-test-password".toCharArray()))
basicAuthHeaderValue("test-admin", new SecureString("x-pack-test-password".toCharArray()))
)
.build();
}
Expand Down Expand Up @@ -338,6 +338,27 @@ public void testSettingsOutputIncludeDomain() throws IOException {
}

public void testXpackUsageOutput() throws IOException {
// Profile 1 that has not activated for more than 30 days
final String uid = randomAlphaOfLength(20);
final String source = SAMPLE_PROFILE_DOCUMENT_TEMPLATE.formatted(uid, uid, Instant.now().minus(31, ChronoUnit.DAYS).toEpochMilli());
final Request indexRequest = new Request("PUT", ".security-profile/_doc/profile_" + uid);
indexRequest.setJsonEntity(source);
indexRequest.addParameter("refresh", "wait_for");
indexRequest.setOptions(
expectWarnings(
"this request accesses system indices: [.security-profile-8], but in a future major version, "
+ "direct access to system indices will be prevented by default"
)
);
assertOK(adminClient().performRequest(indexRequest));

// Profile 2 is disabled
final Map<String, Object> racUserProfile = doActivateProfile();
doSetEnabled((String) racUserProfile.get("uid"), false);

// Profile 3 is enabled and recently activated
doActivateProfile("test-admin", "x-pack-test-password");

final Request xpackUsageRequest = new Request("GET", "_xpack/usage");
xpackUsageRequest.addParameter("filter_path", "security");
final Response xpackUsageResponse = adminClient().performRequest(xpackUsageRequest);
Expand All @@ -354,6 +375,8 @@ public void testXpackUsageOutput() throws IOException {
@SuppressWarnings("unchecked")
final List<String> otherDomainRealms = (List<String>) castToMap(domainsUsage.get("other_domain")).get("realms");
assertThat(otherDomainRealms, containsInAnyOrder("saml1", "ad1"));

assertThat(castToMap(xpackUsageView.get("security.user_profile")), equalTo(Map.of("total", 3, "enabled", 2, "recent", 1)));
}

public void testActivateGracePeriodIsPerNode() throws IOException {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -588,7 +588,7 @@ Collection<Object> createComponents(
) throws Exception {
logger.info("Security is {}", enabled ? "enabled" : "disabled");
if (enabled == false) {
return Collections.singletonList(new SecurityUsageServices(null, null, null, null));
return Collections.singletonList(new SecurityUsageServices(null, null, null, null, null));
}

systemIndices.init(client, clusterService);
Expand Down Expand Up @@ -917,7 +917,7 @@ Collection<Object> createComponents(
)
);

components.add(new SecurityUsageServices(realms, allRolesStore, nativeRoleMappingStore, ipFilter.get()));
components.add(new SecurityUsageServices(realms, allRolesStore, nativeRoleMappingStore, ipFilter.get(), profileService));

cacheInvalidatorRegistry.validate();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import org.elasticsearch.xpack.security.authc.Realms;
import org.elasticsearch.xpack.security.authc.support.mapper.NativeRoleMappingStore;
import org.elasticsearch.xpack.security.authz.store.CompositeRolesStore;
import org.elasticsearch.xpack.security.profile.ProfileService;
import org.elasticsearch.xpack.security.transport.filter.IPFilter;

/**
Expand All @@ -21,11 +22,19 @@ class SecurityUsageServices {
final CompositeRolesStore rolesStore;
final NativeRoleMappingStore roleMappingStore;
final IPFilter ipFilter;
final ProfileService profileService;

SecurityUsageServices(Realms realms, CompositeRolesStore rolesStore, NativeRoleMappingStore roleMappingStore, IPFilter ipFilter) {
SecurityUsageServices(
Realms realms,
CompositeRolesStore rolesStore,
NativeRoleMappingStore roleMappingStore,
IPFilter ipFilter,
ProfileService profileService
) {
this.realms = realms;
this.rolesStore = rolesStore;
this.roleMappingStore = roleMappingStore;
this.ipFilter = ipFilter;
this.profileService = profileService;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
import org.elasticsearch.xpack.security.authc.support.mapper.NativeRoleMappingStore;
import org.elasticsearch.xpack.security.authz.store.CompositeRolesStore;
import org.elasticsearch.xpack.security.operator.OperatorPrivileges;
import org.elasticsearch.xpack.security.profile.ProfileService;
import org.elasticsearch.xpack.security.transport.filter.IPFilter;

import java.util.Arrays;
Expand All @@ -54,6 +55,7 @@ public class SecurityUsageTransportAction extends XPackUsageFeatureTransportActi
private final CompositeRolesStore rolesStore;
private final NativeRoleMappingStore roleMappingStore;
private final IPFilter ipFilter;
private final ProfileService profileService;

@Inject
public SecurityUsageTransportAction(
Expand All @@ -80,6 +82,7 @@ public SecurityUsageTransportAction(
this.rolesStore = securityServices.rolesStore;
this.roleMappingStore = securityServices.roleMappingStore;
this.ipFilter = securityServices.ipFilter;
this.profileService = securityServices.profileService;
}

@Override
Expand Down Expand Up @@ -107,9 +110,10 @@ protected void masterOperation(
final AtomicReference<Map<String, Object>> roleMappingUsageRef = new AtomicReference<>();
final AtomicReference<Map<String, Object>> realmsUsageRef = new AtomicReference<>();
final AtomicReference<Map<String, Object>> domainsUsageRef = new AtomicReference<>();
final AtomicReference<Map<String, Object>> userProfileUsageRef = new AtomicReference<>();

final boolean enabled = XPackSettings.SECURITY_ENABLED.get(settings);
final CountDown countDown = new CountDown(3);
final CountDown countDown = new CountDown(4);
final Runnable doCountDown = () -> {
if (countDown.countDown()) {
var usage = new SecurityFeatureSetUsage(
Expand All @@ -125,7 +129,8 @@ protected void masterOperation(
apiKeyServiceUsage,
fips140Usage,
operatorPrivilegesUsage,
domainsUsageRef.get()
domainsUsageRef.get(),
userProfileUsageRef.get()
);
listener.onResponse(new XPackUsageFeatureResponse(usage));
}
Expand All @@ -147,6 +152,11 @@ protected void masterOperation(
doCountDown.run();
}, listener::onFailure);

final ActionListener<Map<String, Object>> userProfileUsageListener = ActionListener.wrap(userProfileUsage -> {
userProfileUsageRef.set(userProfileUsage);
doCountDown.run();
}, listener::onFailure);

if (rolesStore == null || enabled == false) {
rolesStoreUsageListener.onResponse(Collections.emptyMap());
} else {
Expand All @@ -164,7 +174,11 @@ protected void masterOperation(
domainsUsageRef.set(realms.domainUsageStats());
realms.usageStats(realmsUsageListener);
}

if (profileService == null || enabled == false) {
userProfileUsageListener.onResponse(Map.of());
} else {
profileService.usageStats(userProfileUsageListener);
}
}

static Map<String, Object> sslUsage(Settings settings) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@
import java.io.IOException;
import java.time.Clock;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
Expand Down Expand Up @@ -328,6 +329,78 @@ public void searchProfilesForSubjects(List<Subject> subjects, ActionListener<Sub
}, listener::onFailure));
}

public void usageStats(ActionListener<Map<String, Object>> listener) {
tryFreezeAndCheckIndex(listener.map(response -> { // index does not exist
assert response == null : "only null response can reach here";
return Map.of("total", 0L, "enabled", 0L, "recent", 0L);
})).ifPresent(frozenProfileIndex -> {
final MultiSearchRequest multiSearchRequest = client.prepareMultiSearch()
.add(
client.prepareSearch(SECURITY_PROFILE_ALIAS)
.setQuery(QueryBuilders.boolQuery().filter(QueryBuilders.existsQuery("user_profile.uid")))
.setSize(0)
.setTrackTotalHits(true)
.request()
)
.add(
client.prepareSearch(SECURITY_PROFILE_ALIAS)
.setQuery(QueryBuilders.boolQuery().filter(QueryBuilders.termQuery("user_profile.enabled", true)))
.setSize(0)
.setTrackTotalHits(true)
.request()
)
.add(
client.prepareSearch(SECURITY_PROFILE_ALIAS)
.setQuery(
QueryBuilders.boolQuery()
.filter(QueryBuilders.termQuery("user_profile.enabled", true))
.filter(
QueryBuilders.rangeQuery("user_profile.last_synchronized")
.gt(Instant.now().minus(30, ChronoUnit.DAYS).toEpochMilli())
)
)
.setSize(0)
.setTrackTotalHits(true)
.request()
)
.request();

frozenProfileIndex.checkIndexVersionThenExecute(
listener::onFailure,
() -> executeAsyncWithOrigin(
client,
getActionOrigin(),
MultiSearchAction.INSTANCE,
multiSearchRequest,
ActionListener.wrap(multiSearchResponse -> {
final MultiSearchResponse.Item[] items = multiSearchResponse.getResponses();
assert items.length == 3;
final Map<String, Object> usage = new HashMap<>();
if (items[0].isFailure()) {
logger.debug("error on counting total profiles", items[0].getFailure());
usage.put("total", 0L);
} else {
usage.put("total", items[0].getResponse().getHits().getTotalHits().value);
}
if (items[1].isFailure()) {
logger.debug("error on counting enabled profiles", items[0].getFailure());
usage.put("enabled", 0L);
} else {
usage.put("enabled", items[1].getResponse().getHits().getTotalHits().value);
}
if (items[2].isFailure()) {
logger.debug("error on counting recent profiles", items[0].getFailure());
usage.put("recent", 0L);
} else {
usage.put("recent", items[2].getResponse().getHits().getTotalHits().value);
}
listener.onResponse(usage);
}, listener::onFailure)
)
);
});
}

// package private for testing
SearchRequest buildSearchRequestForSuggest(SuggestProfilesRequest request, TaskId parentTaskId) {
final BoolQueryBuilder query = QueryBuilders.boolQuery().filter(QueryBuilders.termQuery("user_profile.enabled", true));
Expand Down

0 comments on commit bfda66a

Please sign in to comment.