names = traverseAllPages(regularAdmin, apiPath("roles"), CType.ROLES, 100, "asc", null);
+ assertThat("Pagination must not leak hidden entities to non-admin callers", names, not(hasItem("hidden_pagination_role")));
+ }
+ }
+
+ // ---------------------------------------------------------------------
+ // Response shape – wrapper key matches endpoint name
+ // ---------------------------------------------------------------------
+
+ @Test
+ public void responseShape_wrapperKeyMatchesEndpointCType() throws Exception {
+ try (TestRestClient client = localCluster.getRestClient(ADMIN_USER)) {
+ client.putJson(apiPath("actiongroups", "shape_ag_1"), ACTION_GROUP_BODY);
+ final HttpResponse resp = client.get(apiPath("actiongroups") + "?size=10&sort=asc");
+ assertThat(resp.getBody(), resp.getStatusCode(), is(200));
+ final JsonNode body = DefaultObjectMapper.readTree(resp.getBody());
+ assertThat(body.has("next_token"), is(true));
+ assertThat(body.has(CType.ACTIONGROUPS.toLCString()), is(true));
+ // Sanity: no leakage of entries at the top level
+ assertThat(body.has("shape_ag_1"), is(false));
+ }
+ }
+
+ // ---------------------------------------------------------------------
+ // Small guard against future accidental double-registration
+ // ---------------------------------------------------------------------
+
+ @Test
+ public void endpointBinding_paginationHelperConstantsMatchQueryStringForm() {
+ assertThat(PaginationHelper.PARAM_SIZE, equalTo("size"));
+ assertThat(PaginationHelper.PARAM_NEXT_TOKEN, equalTo("next_token"));
+ assertThat(PaginationHelper.PARAM_SORT, equalTo("sort"));
+ }
+
+ // ---------------------------------------------------------------------
+ // Sanity for empty next_token param
+ // ---------------------------------------------------------------------
+
+ @Test
+ public void emptyNextToken_treatedAsAbsent() throws Exception {
+ try (TestRestClient client = localCluster.getRestClient(ADMIN_USER)) {
+ final HttpResponse resp = client.get(apiPath("roles") + "?size=5&sort=asc&next_token=");
+ assertThat(resp.getBody(), resp.getStatusCode(), is(200));
+ final JsonNode body = DefaultObjectMapper.readTree(resp.getBody());
+ assertThat("Empty next_token should be treated as first page", body.has("next_token"), is(true));
+ }
+ }
+}
diff --git a/src/main/java/org/opensearch/security/dlic/rest/api/AbstractApiAction.java b/src/main/java/org/opensearch/security/dlic/rest/api/AbstractApiAction.java
index 435399a1f8..bf91f44644 100644
--- a/src/main/java/org/opensearch/security/dlic/rest/api/AbstractApiAction.java
+++ b/src/main/java/org/opensearch/security/dlic/rest/api/AbstractApiAction.java
@@ -52,6 +52,7 @@
import org.opensearch.security.action.configupdate.ConfigUpdateResponse;
import org.opensearch.security.action.configupdate.SecurityConfigWriteAction;
import org.opensearch.security.action.configupdate.SecurityConfigWriteRequest;
+import org.opensearch.security.dlic.rest.api.pagination.PaginationHelper;
import org.opensearch.security.dlic.rest.support.Utils;
import org.opensearch.security.dlic.rest.validation.EndpointValidator;
import org.opensearch.security.dlic.rest.validation.RequestContentValidator;
@@ -692,6 +693,11 @@ protected final RestChannelConsumer prepareRequest(RestRequest request, NodeClie
// override consumeParameters — and don't call super — still don't reject
// ?wait_for_completion=... as an unrecognized parameter.
request.paramAsBoolean("wait_for_completion", true);
+ // Same rationale for the cursor-based pagination parameters used by collection GETs.
+ // Subclasses opt into pagination behavior via
+ // {@link RequestHandler.RequestHandlersBuilder#onCollectionGetRequest}; consuming here
+ // means unopted endpoints simply ignore the parameters rather than 400-ing on them.
+ PaginationHelper.consumeParameters(request);
// check if .opendistro_security index has been initialized
if (!ensureIndexExists()) {
diff --git a/src/main/java/org/opensearch/security/dlic/rest/api/ActionGroupsApiAction.java b/src/main/java/org/opensearch/security/dlic/rest/api/ActionGroupsApiAction.java
index cf54809a32..a99ab1ad68 100644
--- a/src/main/java/org/opensearch/security/dlic/rest/api/ActionGroupsApiAction.java
+++ b/src/main/java/org/opensearch/security/dlic/rest/api/ActionGroupsApiAction.java
@@ -117,7 +117,9 @@ protected CType> getConfigType() {
}
private void actionGroupsApiRequestHandlers(RequestHandler.RequestHandlersBuilder requestHandlersBuilder) {
- requestHandlersBuilder.onChangeRequest(Method.PATCH, this::processPatchRequest).override(Method.POST, methodNotImplementedHandler);
+ requestHandlersBuilder.onCollectionGetRequest(getConfigType(), this::processGetRequest)
+ .onChangeRequest(Method.PATCH, this::processPatchRequest)
+ .override(Method.POST, methodNotImplementedHandler);
}
@Override
diff --git a/src/main/java/org/opensearch/security/dlic/rest/api/InternalUsersApiAction.java b/src/main/java/org/opensearch/security/dlic/rest/api/InternalUsersApiAction.java
index cdc95d1b7c..6544319d59 100644
--- a/src/main/java/org/opensearch/security/dlic/rest/api/InternalUsersApiAction.java
+++ b/src/main/java/org/opensearch/security/dlic/rest/api/InternalUsersApiAction.java
@@ -142,7 +142,8 @@ protected boolean supportsAsync() {
}
private void internalUsersApiRequestHandlers(RequestHandler.RequestHandlersBuilder requestHandlersBuilder) {
- requestHandlersBuilder.onGetRequest(
+ requestHandlersBuilder.onCollectionGetRequest(
+ getConfigType(),
request -> ValidationResult.success(request).map(this::processGetRequest).map(securityConfiguration -> {
final var configuration = securityConfiguration.configuration();
filterUsers(configuration, filterParam(request));
diff --git a/src/main/java/org/opensearch/security/dlic/rest/api/NodesDnApiAction.java b/src/main/java/org/opensearch/security/dlic/rest/api/NodesDnApiAction.java
index 0b21f678b4..90ac829794 100644
--- a/src/main/java/org/opensearch/security/dlic/rest/api/NodesDnApiAction.java
+++ b/src/main/java/org/opensearch/security/dlic/rest/api/NodesDnApiAction.java
@@ -123,13 +123,15 @@ protected void consumeParameters(final RestRequest request) {
}
private void nodesDnApiRequestHandlers(RequestHandler.RequestHandlersBuilder requestHandlersBuilder) {
- requestHandlersBuilder.verifyAccessForAllMethods().onGetRequest(request -> processGetRequest(request).map(securityConfiguration -> {
- if (request.paramAsBoolean("show_all", false)) {
- final var configuration = securityConfiguration.configuration();
- addStaticNodesDn(configuration);
- }
- return ValidationResult.success(securityConfiguration);
- })).onChangeRequest(Method.PATCH, this::processPatchRequest);
+ requestHandlersBuilder.verifyAccessForAllMethods()
+ .onCollectionGetRequest(getConfigType(), request -> processGetRequest(request).map(securityConfiguration -> {
+ if (request.paramAsBoolean("show_all", false)) {
+ final var configuration = securityConfiguration.configuration();
+ addStaticNodesDn(configuration);
+ }
+ return ValidationResult.success(securityConfiguration);
+ }))
+ .onChangeRequest(Method.PATCH, this::processPatchRequest);
}
@SuppressWarnings("unchecked")
diff --git a/src/main/java/org/opensearch/security/dlic/rest/api/RequestHandler.java b/src/main/java/org/opensearch/security/dlic/rest/api/RequestHandler.java
index b3201967fc..817194632a 100644
--- a/src/main/java/org/opensearch/security/dlic/rest/api/RequestHandler.java
+++ b/src/main/java/org/opensearch/security/dlic/rest/api/RequestHandler.java
@@ -26,7 +26,9 @@
import org.opensearch.core.xcontent.ToXContent;
import org.opensearch.rest.RestChannel;
import org.opensearch.rest.RestRequest;
+import org.opensearch.security.dlic.rest.api.pagination.PaginationHelper;
import org.opensearch.security.dlic.rest.validation.ValidationResult;
+import org.opensearch.security.securityconf.impl.CType;
import org.opensearch.security.securityconf.impl.SecurityDynamicConfiguration;
import org.opensearch.transport.client.Client;
@@ -185,6 +187,36 @@ public RequestHandlersBuilder onJsonContentGetRequest(
return this;
}
+ /**
+ * Registers a GET handler that supports opt-in cursor-based pagination via
+ * {@link org.opensearch.security.dlic.rest.api.pagination.PaginationHelper}.
+ *
+ * Callers pass the same {@code CheckedFunction, IOException>}
+ * mapper used with {@link #onGetRequest(CheckedFunction)}. The wrapper then invokes
+ * {@link org.opensearch.security.dlic.rest.api.pagination.PaginationHelper#apply(RestRequest, CType, SecurityConfiguration)}
+ * before rendering. When no pagination parameters were provided, the response retains its
+ * exact pre-existing shape. When any of {@code size}, {@code next_token}, or {@code sort} is
+ * provided, the response is wrapped as {@code {"next_token": ..., "": {entries}}}.
+ *
+ * The provided {@code ctype} binds cursors issued by this endpoint: a {@code next_token}
+ * from a different endpoint or a different sort direction is rejected with HTTP 400.
+ */
+ public RequestHandlersBuilder onCollectionGetRequest(
+ final CType> ctype,
+ final CheckedFunction, IOException> mapper
+ ) {
+ Objects.requireNonNull(ctype, "ctype can't be null");
+ Objects.requireNonNull(mapper, "onCollectionGetRequest request handler can't be null");
+ add(
+ RestRequest.Method.GET,
+ (channel, request, client) -> mapper.apply(request)
+ .map(sc -> PaginationHelper.apply(request, ctype, sc))
+ .valid(toXContent -> Responses.ok(channel, toXContent))
+ .error((status, toXContent) -> response(channel, status, toXContent))
+ );
+ return this;
+ }
+
public RequestHandlersBuilder onChangeRequest(
final RestRequest.Method method,
final CheckedFunction, IOException> mapper
diff --git a/src/main/java/org/opensearch/security/dlic/rest/api/RolesApiAction.java b/src/main/java/org/opensearch/security/dlic/rest/api/RolesApiAction.java
index 0d111c73eb..703f4cf57e 100644
--- a/src/main/java/org/opensearch/security/dlic/rest/api/RolesApiAction.java
+++ b/src/main/java/org/opensearch/security/dlic/rest/api/RolesApiAction.java
@@ -148,7 +148,9 @@ protected boolean supportsAsync() {
}
private void rolesApiRequestHandlers(RequestHandler.RequestHandlersBuilder requestHandlersBuilder) {
- requestHandlersBuilder.onChangeRequest(Method.PATCH, this::processPatchRequest).override(Method.POST, methodNotImplementedHandler);
+ requestHandlersBuilder.onCollectionGetRequest(getConfigType(), this::processGetRequest)
+ .onChangeRequest(Method.PATCH, this::processPatchRequest)
+ .override(Method.POST, methodNotImplementedHandler);
}
@Override
diff --git a/src/main/java/org/opensearch/security/dlic/rest/api/RolesMappingApiAction.java b/src/main/java/org/opensearch/security/dlic/rest/api/RolesMappingApiAction.java
index 3d3a4f618f..5b295a9feb 100644
--- a/src/main/java/org/opensearch/security/dlic/rest/api/RolesMappingApiAction.java
+++ b/src/main/java/org/opensearch/security/dlic/rest/api/RolesMappingApiAction.java
@@ -69,7 +69,9 @@ public RolesMappingApiAction(
) {
super(Endpoint.ROLESMAPPING, clusterService, threadPool, securityApiDependencies);
this.requestHandlersBuilder.configureRequestHandlers(
- builder -> builder.onChangeRequest(Method.PATCH, this::processPatchRequest).override(Method.POST, methodNotImplementedHandler)
+ builder -> builder.onCollectionGetRequest(getConfigType(), this::processGetRequest)
+ .onChangeRequest(Method.PATCH, this::processPatchRequest)
+ .override(Method.POST, methodNotImplementedHandler)
);
}
diff --git a/src/main/java/org/opensearch/security/dlic/rest/api/TenantsApiAction.java b/src/main/java/org/opensearch/security/dlic/rest/api/TenantsApiAction.java
index 86038a9642..950beddf1e 100644
--- a/src/main/java/org/opensearch/security/dlic/rest/api/TenantsApiAction.java
+++ b/src/main/java/org/opensearch/security/dlic/rest/api/TenantsApiAction.java
@@ -79,7 +79,10 @@ public TenantsApiAction(
final SecurityApiDependencies securityApiDependencies
) {
super(Endpoint.TENANTS, clusterService, threadPool, securityApiDependencies);
- this.requestHandlersBuilder.configureRequestHandlers(builder -> builder.onChangeRequest(Method.PATCH, this::processPatchRequest));
+ this.requestHandlersBuilder.configureRequestHandlers(
+ builder -> builder.onCollectionGetRequest(getConfigType(), this::processGetRequest)
+ .onChangeRequest(Method.PATCH, this::processPatchRequest)
+ );
}
@Override
diff --git a/src/main/java/org/opensearch/security/dlic/rest/api/pagination/PaginationHelper.java b/src/main/java/org/opensearch/security/dlic/rest/api/pagination/PaginationHelper.java
new file mode 100644
index 0000000000..1c6a8a62d4
--- /dev/null
+++ b/src/main/java/org/opensearch/security/dlic/rest/api/pagination/PaginationHelper.java
@@ -0,0 +1,394 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ *
+ * Modifications Copyright OpenSearch Contributors. See
+ * GitHub history for details.
+ */
+
+package org.opensearch.security.dlic.rest.api.pagination;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Base64;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.TreeMap;
+
+import org.opensearch.core.rest.RestStatus;
+import org.opensearch.core.xcontent.ToXContent;
+import org.opensearch.core.xcontent.XContentBuilder;
+import org.opensearch.rest.RestRequest;
+import org.opensearch.security.DefaultObjectMapper;
+import org.opensearch.security.dlic.rest.api.SecurityConfiguration;
+import org.opensearch.security.dlic.rest.validation.ValidationResult;
+import org.opensearch.security.securityconf.impl.CType;
+import org.opensearch.security.securityconf.impl.SecurityDynamicConfiguration;
+
+import tools.jackson.core.type.TypeReference;
+import tools.jackson.databind.JsonNode;
+
+import static org.opensearch.security.dlic.rest.api.Responses.badRequestMessage;
+
+/**
+ * Shared pagination utility for Security configuration collection GET endpoints.
+ *
+ * The contract mirrors the OpenSearch {@code _list/*} APIs (see
+ * OpenSearch#14641) so callers
+ * see a consistent surface across cluster and plugin configuration APIs:
+ *
+ * - {@code size} — positive page size, capped at {@value #MAX_PAGE_SIZE}, defaulting to
+ * {@value #DEFAULT_PAGE_SIZE} when pagination is requested.
+ * - {@code next_token} — opaque cursor returned by the preceding page; {@code null} on the final page.
+ * - {@code sort} — {@code asc} or {@code desc}; sorts by configuration entity name; defaults to {@code asc}.
+ *
+ *
+ * Pagination is opt-in per request: unless the caller sets one of {@code size},
+ * {@code next_token}, or {@code sort}, the response retains its exact pre-existing shape. When any
+ * pagination parameter is present, the response is wrapped as:
+ *
+ * {
+ * "next_token": null | "<opaque>",
+ * "<ctype>": { "<name_a>": {...}, "<name_b>": {...} }
+ * }
+ *
+ *
+ * Cursors are bound to (cursor format version, {@link CType}, sort direction, last returned entity
+ * name). Cursors decoded with any mismatched field, tampered content, or an invalid Base64/JSON
+ * payload are rejected with HTTP 400. Because continuation is name-based (lexical), the entity
+ * referenced by a cursor does not need to still exist — additions or deletions before the cursor do
+ * not shift later pages.
+ */
+public final class PaginationHelper {
+
+ public static final String PARAM_SIZE = "size";
+ public static final String PARAM_NEXT_TOKEN = "next_token";
+ public static final String PARAM_SORT = "sort";
+
+ public static final String SORT_ASC = "asc";
+ public static final String SORT_DESC = "desc";
+
+ public static final int DEFAULT_PAGE_SIZE = 100;
+ public static final int MAX_PAGE_SIZE = 1000;
+
+ /** Current opaque-cursor payload format. Bump when the on-wire structure changes. */
+ static final int CURSOR_VERSION = 1;
+
+ private static final String CURSOR_FIELD_VERSION = "v";
+ private static final String CURSOR_FIELD_CTYPE = "c";
+ private static final String CURSOR_FIELD_SORT = "s";
+ private static final String CURSOR_FIELD_LAST_NAME = "n";
+
+ private static final String INVALID_CURSOR_MESSAGE = "Parameter [next_token] is invalid or has been tampered with.";
+ private static final String CROSS_ENDPOINT_CURSOR_MESSAGE =
+ "Parameter [next_token] was issued for a different endpoint. Restart pagination without [next_token].";
+ private static final String CROSS_DIRECTION_CURSOR_MESSAGE =
+ "Parameter [next_token] was issued for a different sort direction. Restart pagination without [next_token].";
+
+ private PaginationHelper() {}
+
+ /**
+ * Returns {@code true} if the caller supplied any of {@link #PARAM_SIZE}, {@link #PARAM_NEXT_TOKEN},
+ * or {@link #PARAM_SORT} — i.e. requested paginated response semantics.
+ */
+ public static boolean isRequested(final RestRequest request) {
+ return request.hasParam(PARAM_SIZE) || request.hasParam(PARAM_NEXT_TOKEN) || request.hasParam(PARAM_SORT);
+ }
+
+ /**
+ * Consumes the pagination query parameters from the request so that callers of
+ * {@code BaseRestHandler} do not reject them as unrecognized. Intended to be invoked once from
+ * {@code prepareRequest}, regardless of whether the endpoint opts into pagination.
+ */
+ public static void consumeParameters(final RestRequest request) {
+ request.param(PARAM_SIZE);
+ request.param(PARAM_NEXT_TOKEN);
+ request.param(PARAM_SORT);
+ }
+
+ /**
+ * Applies pagination to a Security configuration collection GET result if the caller requested
+ * it. Returns a {@link ToXContent} suitable for {@code Responses.ok(channel, ToXContent)}:
+ *
+ * - When no pagination parameters were provided, returns the configuration unchanged so
+ * non-paginated responses retain their exact existing shape.
+ * - When pagination was requested on a collection GET, returns a wrapper that emits
+ * {@code {"next_token": ..., "": {entries}}}.
+ * - When pagination parameters were provided against a single-entity GET, returns
+ * {@link ValidationResult#error(RestStatus, ToXContent)} with HTTP 400.
+ * - When any pagination parameter is invalid (bad size, unknown sort, malformed or
+ * cross-endpoint {@code next_token}), returns HTTP 400.
+ *
+ */
+ public static ValidationResult apply(
+ final RestRequest request,
+ final CType> ctype,
+ final SecurityConfiguration securityConfiguration
+ ) throws IOException {
+ final boolean requested = isRequested(request);
+ final boolean singleEntity = securityConfiguration.maybeEntityName().isPresent();
+
+ if (singleEntity) {
+ if (requested) {
+ return ValidationResult.error(
+ RestStatus.BAD_REQUEST,
+ badRequestMessage("Pagination parameters are not supported on single-entity GET requests.")
+ );
+ }
+ return ValidationResult.success(securityConfiguration.configuration());
+ }
+
+ if (!requested) {
+ return ValidationResult.success(securityConfiguration.configuration());
+ }
+
+ return parseAndValidate(request, ctype).map(
+ params -> ValidationResult.success(paginate(securityConfiguration.configuration(), params, ctype))
+ );
+ }
+
+ static ValidationResult parseAndValidate(final RestRequest request, final CType> ctype) {
+ try {
+ // Sort
+ final String rawSort = request.param(PARAM_SORT, SORT_ASC);
+ if (!SORT_ASC.equals(rawSort) && !SORT_DESC.equals(rawSort)) {
+ return ValidationResult.error(
+ RestStatus.BAD_REQUEST,
+ badRequestMessage("Parameter [sort] must be either [asc] or [desc].")
+ );
+ }
+
+ // Size
+ final String rawSize = request.param(PARAM_SIZE);
+ int size = DEFAULT_PAGE_SIZE;
+ if (rawSize != null) {
+ try {
+ size = Integer.parseInt(rawSize);
+ } catch (NumberFormatException e) {
+ return ValidationResult.error(
+ RestStatus.BAD_REQUEST,
+ badRequestMessage("Parameter [size] must be a positive integer between 1 and " + MAX_PAGE_SIZE + ".")
+ );
+ }
+ if (size < 1 || size > MAX_PAGE_SIZE) {
+ return ValidationResult.error(
+ RestStatus.BAD_REQUEST,
+ badRequestMessage("Parameter [size] must be a positive integer between 1 and " + MAX_PAGE_SIZE + ".")
+ );
+ }
+ }
+ final int resolvedSize = size;
+
+ // next_token (bound to ctype + sort direction; issuer-format-versioned)
+ final String rawToken = request.param(PARAM_NEXT_TOKEN);
+ if (rawToken == null || rawToken.isEmpty()) {
+ return ValidationResult.success(new PaginationParams(resolvedSize, rawSort, null));
+ }
+ return decodeCursor(rawToken, ctype, rawSort).map(
+ lastName -> ValidationResult.success(new PaginationParams(resolvedSize, rawSort, lastName))
+ );
+ } catch (IOException e) {
+ // ValidationResult#map declares IOException; parseAndValidate itself does no IO, so this is unreachable
+ // in practice — surface it as a 400 rather than a 500 to keep the contract predictable.
+ return ValidationResult.error(RestStatus.BAD_REQUEST, badRequestMessage(INVALID_CURSOR_MESSAGE));
+ }
+ }
+
+ private static ValidationResult decodeCursor(final String rawToken, final CType> expectedCtype, final String expectedSort) {
+ final byte[] decoded;
+ try {
+ // URL-safe Base64 without padding, matching {@link #encodeCursor}. Standard Base64
+ // is intentionally NOT accepted so cursors we hand out are round-trip safe through any
+ // HTTP client (including those that form-encode query strings and would corrupt '+').
+ decoded = Base64.getUrlDecoder().decode(rawToken);
+ } catch (IllegalArgumentException e) {
+ return ValidationResult.error(RestStatus.BAD_REQUEST, badRequestMessage(INVALID_CURSOR_MESSAGE));
+ }
+ final String json = new String(decoded, StandardCharsets.UTF_8);
+ final JsonNode node;
+ try {
+ node = DefaultObjectMapper.readTree(json);
+ } catch (IOException e) {
+ return ValidationResult.error(RestStatus.BAD_REQUEST, badRequestMessage(INVALID_CURSOR_MESSAGE));
+ }
+ if (node == null || !node.isObject()) {
+ return ValidationResult.error(RestStatus.BAD_REQUEST, badRequestMessage(INVALID_CURSOR_MESSAGE));
+ }
+
+ final JsonNode versionNode = node.get(CURSOR_FIELD_VERSION);
+ final JsonNode ctypeNode = node.get(CURSOR_FIELD_CTYPE);
+ final JsonNode sortNode = node.get(CURSOR_FIELD_SORT);
+ final JsonNode nameNode = node.get(CURSOR_FIELD_LAST_NAME);
+ if (versionNode == null
+ || !versionNode.isInt()
+ || versionNode.asInt() != CURSOR_VERSION
+ || ctypeNode == null
+ || !ctypeNode.isString()
+ || sortNode == null
+ || !sortNode.isString()
+ || nameNode == null
+ || !nameNode.isString()) {
+ return ValidationResult.error(RestStatus.BAD_REQUEST, badRequestMessage(INVALID_CURSOR_MESSAGE));
+ }
+
+ if (!expectedCtype.toLCString().equals(ctypeNode.asString())) {
+ return ValidationResult.error(RestStatus.BAD_REQUEST, badRequestMessage(CROSS_ENDPOINT_CURSOR_MESSAGE));
+ }
+ if (!expectedSort.equals(sortNode.asString())) {
+ return ValidationResult.error(RestStatus.BAD_REQUEST, badRequestMessage(CROSS_DIRECTION_CURSOR_MESSAGE));
+ }
+
+ return ValidationResult.success(nameNode.asString());
+ }
+
+ static String encodeCursor(final CType> ctype, final String sort, final String lastName) {
+ Objects.requireNonNull(lastName, "lastName");
+ final Map payload = new TreeMap<>();
+ payload.put(CURSOR_FIELD_VERSION, CURSOR_VERSION);
+ payload.put(CURSOR_FIELD_CTYPE, ctype.toLCString());
+ payload.put(CURSOR_FIELD_SORT, sort);
+ payload.put(CURSOR_FIELD_LAST_NAME, lastName);
+ final String json = DefaultObjectMapper.writeValueAsString(payload, false);
+ // URL-safe Base64 without padding so the emitted token can be dropped straight into a
+ // {@code next_token=} query parameter by any HTTP client without further encoding.
+ return Base64.getUrlEncoder().withoutPadding().encodeToString(json.getBytes(StandardCharsets.UTF_8));
+ }
+
+ static PaginatedConfigurationResponse paginate(
+ final SecurityDynamicConfiguration> configuration,
+ final PaginationParams params,
+ final CType> ctype
+ ) {
+ final Map entries = configuration.getCEntries();
+ final List sortedNames = new ArrayList<>(entries.keySet());
+ final Comparator comparator = SORT_ASC.equals(params.sort) ? Comparator.naturalOrder() : Comparator.reverseOrder();
+ Collections.sort(sortedNames, comparator);
+
+ // Lexical continuation from the cursor's last name: consume names strictly beyond it.
+ int startIdx = 0;
+ if (params.lastName != null) {
+ while (startIdx < sortedNames.size() && comparator.compare(sortedNames.get(startIdx), params.lastName) <= 0) {
+ startIdx++;
+ }
+ }
+
+ final int endIdx = Math.min(startIdx + params.size, sortedNames.size());
+ final List pageNames = sortedNames.subList(startIdx, endIdx);
+
+ final String nextToken;
+ if (endIdx < sortedNames.size() && !pageNames.isEmpty()) {
+ nextToken = encodeCursor(ctype, params.sort, pageNames.get(pageNames.size() - 1));
+ } else {
+ nextToken = null;
+ }
+
+ return new PaginatedConfigurationResponse(pageNames, configuration, ctype, nextToken);
+ }
+
+ /**
+ * Parsed and validated pagination parameters.
+ */
+ public static final class PaginationParams {
+
+ final int size;
+ final String sort;
+ final String lastName;
+
+ PaginationParams(final int size, final String sort, final String lastName) {
+ this.size = size;
+ this.sort = sort;
+ this.lastName = lastName;
+ }
+
+ public int size() {
+ return size;
+ }
+
+ public String sort() {
+ return sort;
+ }
+
+ public String lastName() {
+ return lastName;
+ }
+ }
+
+ /**
+ * {@link ToXContent} view that renders the paginated response wrapper:
+ *
+ * {
+ * "next_token": null | "<opaque>",
+ * "<ctype>": {"<name>": {...}, ...}
+ * }
+ *
+ * Ordering of page entries matches the requested sort direction.
+ */
+ public static final class PaginatedConfigurationResponse implements ToXContent {
+
+ private static final TypeReference> TYPE_REF_MSO = new TypeReference<>() {
+ };
+
+ private final List pageNames;
+ private final SecurityDynamicConfiguration> configuration;
+ private final CType> ctype;
+ private final String nextToken;
+
+ PaginatedConfigurationResponse(
+ final List pageNames,
+ final SecurityDynamicConfiguration> configuration,
+ final CType> ctype,
+ final String nextToken
+ ) {
+ this.pageNames = pageNames;
+ this.configuration = configuration;
+ this.ctype = ctype;
+ this.nextToken = nextToken;
+ }
+
+ @Override
+ public XContentBuilder toXContent(final XContentBuilder builder, final Params params) throws IOException {
+ // Serialize the full configuration to a Map exactly as SecurityDynamicConfiguration does,
+ // so page entries render identically to the non-paginated response shape. We then keep
+ // only the entries in `pageNames` (in the ordering requested by the caller).
+ final boolean omitDefaults = params != null && params.paramAsBoolean("omit_defaults", false);
+ final Map full = DefaultObjectMapper.readValue(
+ DefaultObjectMapper.writeValueAsString(configuration, omitDefaults),
+ TYPE_REF_MSO
+ );
+
+ builder.startObject();
+ if (nextToken == null) {
+ builder.nullField(PARAM_NEXT_TOKEN);
+ } else {
+ builder.field(PARAM_NEXT_TOKEN, nextToken);
+ }
+ builder.startObject(ctype.toLCString());
+ for (final String name : pageNames) {
+ final Object entry = full.get(name);
+ if (entry == null) {
+ continue;
+ }
+ builder.field(name, entry);
+ }
+ builder.endObject();
+ builder.endObject();
+ return builder;
+ }
+
+ public String nextToken() {
+ return nextToken;
+ }
+
+ public List pageNames() {
+ return Collections.unmodifiableList(pageNames);
+ }
+ }
+}
diff --git a/src/test/java/org/opensearch/security/dlic/rest/api/pagination/PaginationHelperTest.java b/src/test/java/org/opensearch/security/dlic/rest/api/pagination/PaginationHelperTest.java
new file mode 100644
index 0000000000..f053120fef
--- /dev/null
+++ b/src/test/java/org/opensearch/security/dlic/rest/api/pagination/PaginationHelperTest.java
@@ -0,0 +1,456 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ *
+ * Modifications Copyright OpenSearch Contributors. See
+ * GitHub history for details.
+ */
+
+package org.opensearch.security.dlic.rest.api.pagination;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+import java.util.List;
+import java.util.Map;
+
+import org.junit.Test;
+
+import org.opensearch.common.xcontent.XContentFactory;
+import org.opensearch.core.rest.RestStatus;
+import org.opensearch.core.xcontent.ToXContent;
+import org.opensearch.security.DefaultObjectMapper;
+import org.opensearch.security.dlic.rest.api.SecurityConfiguration;
+import org.opensearch.security.dlic.rest.validation.ValidationResult;
+import org.opensearch.security.securityconf.impl.CType;
+import org.opensearch.security.securityconf.impl.SecurityDynamicConfiguration;
+import org.opensearch.security.securityconf.impl.v7.RoleV7;
+import org.opensearch.security.util.FakeRestRequest;
+
+import tools.jackson.databind.JsonNode;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.contains;
+import static org.hamcrest.Matchers.empty;
+import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.notNullValue;
+import static org.hamcrest.Matchers.nullValue;
+
+public class PaginationHelperTest {
+
+ // ---------------------------------------------------------------------
+ // isRequested / consumeParameters
+ // ---------------------------------------------------------------------
+
+ @Test
+ public void isRequested_returnsFalseWhenNoParams() {
+ final var request = FakeRestRequest.builder().withParams(Map.of()).build();
+ assertThat(PaginationHelper.isRequested(request), is(false));
+ }
+
+ @Test
+ public void isRequested_returnsTrueForAnyPaginationParam() {
+ assertThat(PaginationHelper.isRequested(withParams(Map.of("size", "5"))), is(true));
+ assertThat(PaginationHelper.isRequested(withParams(Map.of("sort", "asc"))), is(true));
+ assertThat(PaginationHelper.isRequested(withParams(Map.of("next_token", "AAAA"))), is(true));
+ }
+
+ // ---------------------------------------------------------------------
+ // parseAndValidate – rejects invalid inputs with 400
+ // ---------------------------------------------------------------------
+
+ @Test
+ public void parseAndValidate_rejectsUnknownSort() {
+ final var request = withParams(Map.of("sort", "sideways"));
+ final var result = PaginationHelper.parseAndValidate(request, CType.ROLES);
+ assertThat(result.isValid(), is(false));
+ assertThat(result.status(), is(RestStatus.BAD_REQUEST));
+ }
+
+ @Test
+ public void parseAndValidate_rejectsNonIntegerSize() {
+ final var request = withParams(Map.of("size", "not-a-number"));
+ final var result = PaginationHelper.parseAndValidate(request, CType.ROLES);
+ assertThat(result.isValid(), is(false));
+ assertThat(result.status(), is(RestStatus.BAD_REQUEST));
+ }
+
+ @Test
+ public void parseAndValidate_rejectsSizeBelowRange() {
+ final var request = withParams(Map.of("size", "0"));
+ final var result = PaginationHelper.parseAndValidate(request, CType.ROLES);
+ assertThat(result.isValid(), is(false));
+ assertThat(result.status(), is(RestStatus.BAD_REQUEST));
+ }
+
+ @Test
+ public void parseAndValidate_rejectsSizeAboveMax() {
+ final var request = withParams(Map.of("size", String.valueOf(PaginationHelper.MAX_PAGE_SIZE + 1)));
+ final var result = PaginationHelper.parseAndValidate(request, CType.ROLES);
+ assertThat(result.isValid(), is(false));
+ assertThat(result.status(), is(RestStatus.BAD_REQUEST));
+ }
+
+ @Test
+ public void parseAndValidate_rejectsMalformedBase64Token() {
+ final var request = withParams(Map.of("next_token", "@@@not-base64@@@"));
+ final var result = PaginationHelper.parseAndValidate(request, CType.ROLES);
+ assertThat(result.isValid(), is(false));
+ assertThat(result.status(), is(RestStatus.BAD_REQUEST));
+ }
+
+ @Test
+ public void parseAndValidate_rejectsNonJsonToken() {
+ final String tampered = Base64.getUrlEncoder().withoutPadding().encodeToString("not json".getBytes(StandardCharsets.UTF_8));
+ final var request = withParams(Map.of("next_token", tampered));
+ final var result = PaginationHelper.parseAndValidate(request, CType.ROLES);
+ assertThat(result.isValid(), is(false));
+ assertThat(result.status(), is(RestStatus.BAD_REQUEST));
+ }
+
+ @Test
+ public void parseAndValidate_rejectsTokenIssuedForDifferentEndpoint() {
+ final String cursor = PaginationHelper.encodeCursor(CType.ACTIONGROUPS, PaginationHelper.SORT_ASC, "ag_5");
+ final var request = withParams(Map.of("next_token", cursor));
+ final var result = PaginationHelper.parseAndValidate(request, CType.ROLES);
+ assertThat(result.isValid(), is(false));
+ assertThat(result.status(), is(RestStatus.BAD_REQUEST));
+ }
+
+ @Test
+ public void parseAndValidate_rejectsTokenIssuedForDifferentSort() {
+ final String cursor = PaginationHelper.encodeCursor(CType.ROLES, PaginationHelper.SORT_ASC, "role_5");
+ final var request = withParams(Map.of("next_token", cursor, "sort", PaginationHelper.SORT_DESC));
+ final var result = PaginationHelper.parseAndValidate(request, CType.ROLES);
+ assertThat(result.isValid(), is(false));
+ assertThat(result.status(), is(RestStatus.BAD_REQUEST));
+ }
+
+ @Test
+ public void parseAndValidate_rejectsCursorWithMismatchedVersion() {
+ final String tampered = tamperCursor(cursor -> cursor.replace("\"v\":1", "\"v\":999"));
+ final var request = withParams(Map.of("next_token", tampered));
+ final var result = PaginationHelper.parseAndValidate(request, CType.ROLES);
+ assertThat(result.isValid(), is(false));
+ assertThat(result.status(), is(RestStatus.BAD_REQUEST));
+ }
+
+ @Test
+ public void parseAndValidate_rejectsCursorPayloadThatIsValidJsonButNotAnObject() {
+ // A syntactically-valid JSON payload of the wrong shape (an array, a number, a string,
+ // etc.) must be rejected — not misinterpreted or NPE'd on missing fields.
+ for (final String rawPayload : List.of("[1,2,3]", "\"just a string\"", "42", "null", "true")) {
+ final String token = Base64.getUrlEncoder().withoutPadding().encodeToString(rawPayload.getBytes(StandardCharsets.UTF_8));
+ final var result = PaginationHelper.parseAndValidate(withParams(Map.of("next_token", token)), CType.ROLES);
+ assertThat("Payload [" + rawPayload + "] should be rejected", result.isValid(), is(false));
+ assertThat(result.status(), is(RestStatus.BAD_REQUEST));
+ }
+ }
+
+ @Test
+ public void parseAndValidate_rejectsCursorMissingRequiredField() {
+ // Drop each of the four required fields in turn and verify each variant is rejected.
+ final Map validPayload = Map.of("v", "1", "c", "roles", "s", "asc", "n", "role_a");
+ for (final String toDrop : validPayload.keySet()) {
+ final var payload = new java.util.LinkedHashMap<>(validPayload);
+ payload.remove(toDrop);
+ final String json = "{" + String.join(",", payload.entrySet().stream().map(e -> {
+ // Keep "v" numeric, other fields string
+ final String v = "v".equals(e.getKey()) ? e.getValue() : "\"" + e.getValue() + "\"";
+ return "\"" + e.getKey() + "\":" + v;
+ }).toList()) + "}";
+ final String token = Base64.getUrlEncoder().withoutPadding().encodeToString(json.getBytes(StandardCharsets.UTF_8));
+ final var result = PaginationHelper.parseAndValidate(withParams(Map.of("next_token", token)), CType.ROLES);
+ assertThat("Cursor missing field [" + toDrop + "] should be rejected", result.isValid(), is(false));
+ assertThat(result.status(), is(RestStatus.BAD_REQUEST));
+ }
+ }
+
+ @Test
+ public void parseAndValidate_rejectsCursorWithWrongFieldTypes() {
+ // Version-as-string, ctype-as-int, sort-as-array, name-as-null — each should be rejected
+ // rather than accepted with a coerced value or crashed with an NPE.
+ final List badJsonPayloads = List.of(
+ "{\"v\":\"1\",\"c\":\"roles\",\"s\":\"asc\",\"n\":\"role_a\"}", // v as string
+ "{\"v\":1,\"c\":123,\"s\":\"asc\",\"n\":\"role_a\"}", // c as int
+ "{\"v\":1,\"c\":\"roles\",\"s\":[\"asc\"],\"n\":\"role_a\"}", // s as array
+ "{\"v\":1,\"c\":\"roles\",\"s\":\"asc\",\"n\":null}" // n as null
+ );
+ for (final String json : badJsonPayloads) {
+ final String token = Base64.getUrlEncoder().withoutPadding().encodeToString(json.getBytes(StandardCharsets.UTF_8));
+ final var result = PaginationHelper.parseAndValidate(withParams(Map.of("next_token", token)), CType.ROLES);
+ assertThat("Cursor with wrong types [" + json + "] should be rejected", result.isValid(), is(false));
+ assertThat(result.status(), is(RestStatus.BAD_REQUEST));
+ }
+ }
+
+ @Test
+ public void parseAndValidate_acceptsMinimalValidRequest() throws IOException {
+ final var request = withParams(Map.of("size", "10"));
+ final var result = PaginationHelper.parseAndValidate(request, CType.ROLES);
+ assertThat(result.isValid(), is(true));
+ result.valid(params -> {
+ assertThat(params.size(), is(10));
+ assertThat(params.sort(), is(PaginationHelper.SORT_ASC));
+ assertThat(params.lastName(), is(nullValue()));
+ });
+ }
+
+ @Test
+ public void encodeCursor_producesUrlSafeBase64() {
+ // Force a payload that would use every non-alphanumeric byte in standard Base64 output
+ // ('+' and '/'). URL-safe encoding must instead use '-' and '_' and drop '=' padding, so
+ // the emitted cursor can be dropped directly into a query string without further encoding.
+ final String cursor = PaginationHelper.encodeCursor(CType.ROLES, PaginationHelper.SORT_ASC, "\uFFFF\uFEFF\u00FF\u007F\u001F");
+ assertThat("Cursor must not contain standard-Base64 '+'", cursor.contains("+"), is(false));
+ assertThat("Cursor must not contain standard-Base64 '/'", cursor.contains("/"), is(false));
+ assertThat("Cursor must not contain Base64 padding '='", cursor.contains("="), is(false));
+ }
+
+ @Test
+ public void cursorRoundTrip_preservesUnicodeLastName() throws IOException {
+ // Non-ASCII entity name — encode, then decode via parseAndValidate — the recovered lastName
+ // must match byte-for-byte. Guards against silent character corruption in the codec.
+ final String weirdName = "role-\u00E9\u4E2D\uD83D\uDE00";
+ final String cursor = PaginationHelper.encodeCursor(CType.ROLES, PaginationHelper.SORT_ASC, weirdName);
+ final var request = withParams(Map.of("next_token", cursor));
+ final var result = PaginationHelper.parseAndValidate(request, CType.ROLES);
+ assertThat(result.isValid(), is(true));
+ result.valid(params -> assertThat(params.lastName(), is(weirdName)));
+ }
+
+ // ---------------------------------------------------------------------
+ // apply – single-entity GET must reject pagination params
+ // ---------------------------------------------------------------------
+
+ @Test
+ public void apply_rejectsPaginationOnSingleEntityGet() throws IOException {
+ final var request = withParams(Map.of("size", "5", "sort", "asc"));
+ final var configuration = rolesConfig(List.of("role_a", "role_b"));
+ final var securityConfig = SecurityConfiguration.of("role_a", configuration);
+ final var result = PaginationHelper.apply(request, CType.ROLES, securityConfig);
+ assertThat(result.isValid(), is(false));
+ assertThat(result.status(), is(RestStatus.BAD_REQUEST));
+ }
+
+ @Test
+ public void apply_returnsUnchangedConfigurationWhenNoPaginationParams() throws IOException {
+ final var request = withParams(Map.of());
+ final var configuration = rolesConfig(List.of("role_a", "role_b"));
+ final var securityConfig = SecurityConfiguration.of(null, configuration);
+ final var result = PaginationHelper.apply(request, CType.ROLES, securityConfig);
+ assertThat(result.isValid(), is(true));
+ // When no pagination parameters, the raw configuration is returned so responses remain
+ // byte-identical to the pre-existing shape.
+ final JsonNode json = renderToJson(result);
+ assertThat(json.has("next_token"), is(false));
+ assertThat(json.has("role_a"), is(true));
+ assertThat(json.has("role_b"), is(true));
+ }
+
+ // ---------------------------------------------------------------------
+ // Pagination traversal – asc/desc, multiple pages, terminal null
+ // ---------------------------------------------------------------------
+
+ @Test
+ public void pagination_traversalAscending_multiPage() throws IOException {
+ final var configuration = rolesConfig(List.of("d", "a", "c", "b"));
+ // Page 1 (size=2, asc): expect [a, b], next_token != null
+ final var page1 = renderToJson(
+ PaginationHelper.apply(
+ withParams(Map.of("size", "2", "sort", "asc")),
+ CType.ROLES,
+ SecurityConfiguration.of(null, configuration)
+ )
+ );
+ assertThat(page1.get("next_token").isString(), is(true));
+ assertThat(pageEntryNames(page1), contains("a", "b"));
+
+ // Page 2 using returned cursor: expect [c, d], next_token == null (terminal)
+ final String token1 = page1.get("next_token").asString();
+ final var page2 = renderToJson(
+ PaginationHelper.apply(
+ withParams(Map.of("size", "2", "sort", "asc", "next_token", token1)),
+ CType.ROLES,
+ SecurityConfiguration.of(null, configuration)
+ )
+ );
+ assertThat(page2.get("next_token").isNull(), is(true));
+ assertThat(pageEntryNames(page2), contains("c", "d"));
+ }
+
+ @Test
+ public void pagination_traversalDescending_multiPage() throws IOException {
+ final var configuration = rolesConfig(List.of("d", "a", "c", "b"));
+ final var page1 = renderToJson(
+ PaginationHelper.apply(
+ withParams(Map.of("size", "2", "sort", "desc")),
+ CType.ROLES,
+ SecurityConfiguration.of(null, configuration)
+ )
+ );
+ assertThat(page1.get("next_token").isString(), is(true));
+ assertThat(pageEntryNames(page1), contains("d", "c"));
+
+ final String token1 = page1.get("next_token").asString();
+ final var page2 = renderToJson(
+ PaginationHelper.apply(
+ withParams(Map.of("size", "2", "sort", "desc", "next_token", token1)),
+ CType.ROLES,
+ SecurityConfiguration.of(null, configuration)
+ )
+ );
+ assertThat(page2.get("next_token").isNull(), is(true));
+ assertThat(pageEntryNames(page2), contains("b", "a"));
+ }
+
+ @Test
+ public void pagination_terminalPageEqualsFullSet_nullToken() throws IOException {
+ final var configuration = rolesConfig(List.of("a", "b"));
+ final var page = renderToJson(
+ PaginationHelper.apply(
+ withParams(Map.of("size", "5", "sort", "asc")),
+ CType.ROLES,
+ SecurityConfiguration.of(null, configuration)
+ )
+ );
+ assertThat(page.get("next_token").isNull(), is(true));
+ assertThat(pageEntryNames(page), contains("a", "b"));
+ }
+
+ @Test
+ public void pagination_emptyCollection_yieldsEmptyPageAndNullToken() throws IOException {
+ final var configuration = rolesConfig(List.of());
+ final var page = renderToJson(
+ PaginationHelper.apply(
+ withParams(Map.of("size", "5", "sort", "asc")),
+ CType.ROLES,
+ SecurityConfiguration.of(null, configuration)
+ )
+ );
+ assertThat(page.get("next_token").isNull(), is(true));
+ assertThat(page.get("roles"), is(notNullValue()));
+ assertThat(pageEntryNames(page), is(empty()));
+ }
+
+ // ---------------------------------------------------------------------
+ // Lexical continuation – deletions/additions between pages do not shift
+ // ---------------------------------------------------------------------
+
+ @Test
+ public void pagination_lexicalContinuation_deletedCursorNameStillResumesCorrectly() throws IOException {
+ // Page 1 sees roles [a, b, c, d]; caller asks for size=2 asc => returns [a, b], cursor "b".
+ final var configPage1 = rolesConfig(List.of("a", "b", "c", "d"));
+ final var page1 = renderToJson(
+ PaginationHelper.apply(withParams(Map.of("size", "2", "sort", "asc")), CType.ROLES, SecurityConfiguration.of(null, configPage1))
+ );
+ assertThat(pageEntryNames(page1), contains("a", "b"));
+ final String cursor = page1.get("next_token").asString();
+
+ // Between requests, role "b" (the cursor's last-name) was deleted. Also role "a" (before
+ // the cursor) was deleted, and a new role "aa" was inserted before the cursor. Neither
+ // should shift the continuation — we must still resume from names strictly after "b".
+ final var configPage2 = rolesConfig(List.of("aa", "c", "d"));
+ final var page2 = renderToJson(
+ PaginationHelper.apply(
+ withParams(Map.of("size", "2", "sort", "asc", "next_token", cursor)),
+ CType.ROLES,
+ SecurityConfiguration.of(null, configPage2)
+ )
+ );
+ assertThat(pageEntryNames(page2), contains("c", "d"));
+ assertThat(page2.get("next_token").isNull(), is(true));
+ }
+
+ // ---------------------------------------------------------------------
+ // Response shape – contains paginated entity key and next_token
+ // ---------------------------------------------------------------------
+
+ @Test
+ public void pagination_responseShape_wrapsUnderCTypeKey() throws IOException {
+ final var configuration = rolesConfig(List.of("role_a"));
+ final var page = renderToJson(
+ PaginationHelper.apply(
+ withParams(Map.of("size", "10", "sort", "asc")),
+ CType.ROLES,
+ SecurityConfiguration.of(null, configuration)
+ )
+ );
+ assertThat(page.has("next_token"), is(true));
+ assertThat(page.has(CType.ROLES.toLCString()), is(true));
+ // No mixed shape: entries must live under the wrapper, not at the top level
+ assertThat(page.has("role_a"), is(false));
+ }
+
+ // ---------------------------------------------------------------------
+ // Helpers
+ // ---------------------------------------------------------------------
+
+ private static FakeRestRequest withParams(final Map params) {
+ return FakeRestRequest.builder().withParams(params).build();
+ }
+
+ /**
+ * Renders a valid {@link ValidationResult} of a {@link ToXContent} to a JSON node so tests can
+ * assert on the wire shape. Fails the test if the result is not valid.
+ */
+ private static JsonNode renderToJson(final ValidationResult result) throws IOException {
+ if (!result.isValid()) {
+ throw new AssertionError("Expected a valid pagination result but got status " + result.status());
+ }
+ // Extract the ToXContent via the terminal `valid` consumer.
+ final ToXContent[] captured = new ToXContent[1];
+ result.valid(toXContent -> captured[0] = toXContent);
+ try (var builder = XContentFactory.jsonBuilder()) {
+ captured[0].toXContent(builder, ToXContent.EMPTY_PARAMS);
+ return DefaultObjectMapper.readTree(builder.toString());
+ }
+ }
+
+ /**
+ * Builds a {@link SecurityDynamicConfiguration} of roles containing the given names, each with
+ * a trivial {@code cluster_permissions: [*]} body — enough to exercise Jackson serialization
+ * within the pagination helper.
+ */
+ private static SecurityDynamicConfiguration rolesConfig(final List names) throws IOException {
+ final var mapper = DefaultObjectMapper.objectMapper();
+ // Use a LinkedHashMap-backed ObjectNode so JSON encoding is deterministic across runs.
+ final var config = mapper.createObjectNode();
+ // _meta is expected on the raw index format; the API removes it during omitSensitiveData.
+ // For unit-test purposes we omit it — SecurityDynamicConfiguration.fromJson tolerates its absence
+ // when the type matches the constructor.
+ for (final String name : names) {
+ final var role = mapper.createObjectNode();
+ role.set("cluster_permissions", mapper.createArrayNode().add("*"));
+ config.set(name, role);
+ }
+ // Attach a _meta so fromJson accepts the payload.
+ config.set("_meta", mapper.createObjectNode().put("type", CType.ROLES.toLCString()).put("config_version", 2));
+ return SecurityDynamicConfiguration.fromJson(mapper.writeValueAsString(config), CType.ROLES, 2, 1, 1);
+ }
+
+ /**
+ * Produces a valid cursor for {@code CType.ROLES} at asc order for "role_a", then applies the
+ * given text transformation to simulate a tampered token. Uses URL-safe Base64 to match the
+ * production {@link PaginationHelper#encodeCursor} encoding.
+ */
+ private static String tamperCursor(final java.util.function.Function mutator) {
+ final String cursor = PaginationHelper.encodeCursor(CType.ROLES, PaginationHelper.SORT_ASC, "role_a");
+ final String decoded = new String(Base64.getUrlDecoder().decode(cursor), StandardCharsets.UTF_8);
+ final String mutated = mutator.apply(decoded);
+ return Base64.getUrlEncoder().withoutPadding().encodeToString(mutated.getBytes(StandardCharsets.UTF_8));
+ }
+
+ /**
+ * Extracts the ordered list of entry names under the paginated response's ctype-keyed wrapper.
+ */
+ private static List pageEntryNames(final JsonNode page) {
+ final JsonNode wrapper = page.get(CType.ROLES.toLCString());
+ final List out = new java.util.ArrayList<>();
+ wrapper.propertyNames().forEach(out::add);
+ return out;
+ }
+}