From decd858e429c51164771728da6043abe435fe576 Mon Sep 17 00:00:00 2001 From: Vishnutheep B Date: Sat, 8 Aug 2026 23:04:05 +0530 Subject: [PATCH 1/8] Add pagination support for Collection APIs Signed-off-by: Vishnutheep B --- .../dlic/rest/api/AbstractApiAction.java | 60 ++++++++++ .../dlic/rest/api/InternalUsersApiAction.java | 2 +- .../dlic/rest/api/NodesDnApiAction.java | 2 +- .../dlic/rest/api/RequestHandler.java | 24 ++++ .../rest/api/pagination/PaginationCursor.java | 113 ++++++++++++++++++ .../rest/api/pagination/PaginationParams.java | 48 ++++++++ .../pagination/PaginationRequestParser.java | 86 +++++++++++++ .../rest/api/pagination/PaginationResult.java | 78 ++++++++++++ .../dlic/rest/api/pagination/Paginator.java | 94 +++++++++++++++ 9 files changed, 505 insertions(+), 2 deletions(-) create mode 100644 src/main/java/org/opensearch/security/dlic/rest/api/pagination/PaginationCursor.java create mode 100644 src/main/java/org/opensearch/security/dlic/rest/api/pagination/PaginationParams.java create mode 100644 src/main/java/org/opensearch/security/dlic/rest/api/pagination/PaginationRequestParser.java create mode 100644 src/main/java/org/opensearch/security/dlic/rest/api/pagination/PaginationResult.java create mode 100644 src/main/java/org/opensearch/security/dlic/rest/api/pagination/Paginator.java 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 71b3556092..3b09942d64 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 @@ -49,6 +49,11 @@ import org.opensearch.security.action.configupdate.ConfigUpdateAction; import org.opensearch.security.action.configupdate.ConfigUpdateRequest; import org.opensearch.security.action.configupdate.ConfigUpdateResponse; +import org.opensearch.security.dlic.rest.api.pagination.PaginationCursor; +import org.opensearch.security.dlic.rest.api.pagination.PaginationParams; +import org.opensearch.security.dlic.rest.api.pagination.PaginationRequestParser; +import org.opensearch.security.dlic.rest.api.pagination.PaginationResult; +import org.opensearch.security.dlic.rest.api.pagination.Paginator; import org.opensearch.security.dlic.rest.support.Utils; import org.opensearch.security.dlic.rest.validation.EndpointValidator; import org.opensearch.security.dlic.rest.validation.RequestContentValidator; @@ -124,10 +129,23 @@ private void buildDefaultRequestHandlers(final RequestHandler.RequestHandlersBui .add(Method.POST, methodNotImplementedHandler) .add(Method.PATCH, methodNotImplementedHandler) .onGetRequest(this::processGetRequest) + .withPaginatedGetRequest(this::routeGetRequest) .onChangeRequest(Method.DELETE, this::processDeleteRequest) .onChangeRequest(Method.PUT, this::processPutRequest); } + /** + * Routes a GET to the paginated path when pagination parameters are present, + * or falls back to the legacy handler. + * + */ + private ValidationResult routeGetRequest(final RestRequest request) throws IOException { + if (PaginationRequestParser.isPaginationRequested(request)) { + return processPaginatedGetRequest(request); + } + return null; + } + protected final ValidationResult processDeleteRequest(final RestRequest request) throws IOException { return endpointValidator.withRequiredEntityName(nameParam(request)) .map(entityName -> loadConfiguration(entityName, false)) @@ -150,6 +168,45 @@ protected final ValidationResult processGetRequest(final }).orElse(ValidationResult.success(securityConfiguration))); } + /** + * Handles a collection GET with pagination parameters. + */ + protected ValidationResult processPaginatedGetRequest(final RestRequest request) throws IOException { + return PaginationRequestParser.parse(request).map(params -> { + if (nameParam(request) != null) { + return ValidationResult.error( + RestStatus.BAD_REQUEST, + badRequestMessage("Pagination parameters are not supported for single-entity GET requests.") + ); + } + return loadConfiguration(getConfigType(), true, true).map(configuration -> { + if (params.hasCursor()) { + return PaginationCursor.decode(params.nextToken, getConfigType(), params.sort) + .map(cursor -> buildPaginatedPage(configuration, params, cursor)); + } + return buildPaginatedPage(configuration, params, null); + }); + }); + } + + /** + * Builds a single page from the fully-loaded configuration. + * + * @param configuration fully loaded and redacted configuration + * @param params validated pagination parameters + * @param cursor pre-validated cursor from a prior page + * @return a {@link ValidationResult} + */ + @SuppressWarnings("unchecked") + protected ValidationResult buildPaginatedPage( + final SecurityDynamicConfiguration configuration, + final PaginationParams params, + final PaginationCursor cursor + ) { + final PaginationResult page = Paginator.paginate((Map) configuration.getCEntries(), params, cursor, getConfigType()); + return ValidationResult.success(page); + } + /** * Process patch requests for all types of configuration, which can be one entity in the URI or a list of entities in the request body. **/ @@ -649,6 +706,9 @@ protected final RestChannelConsumer prepareRequest(RestRequest request, NodeClie */ protected void consumeParameters(final RestRequest request) { request.param("name"); + request.param(PaginationRequestParser.PARAM_SIZE); + request.param(PaginationRequestParser.PARAM_SORT); + request.param(PaginationRequestParser.PARAM_NEXT_TOKEN); } @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 2c71b888fa..04bfc94acf 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 @@ -59,7 +59,7 @@ public class InternalUsersApiAction extends AbstractApiAction { @Override protected void consumeParameters(final RestRequest request) { - request.param("name"); + super.consumeParameters(request); request.param("filterBy"); } 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..fda92aecc1 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 @@ -118,7 +118,7 @@ protected CType getConfigType() { @Override protected void consumeParameters(final RestRequest request) { - request.param("name"); + super.consumeParameters(request); request.param("show_all"); } 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 35048c87cc..917cdeddd2 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 @@ -142,6 +142,30 @@ public RequestHandlersBuilder onGetRequest( return this; } + /** + * Registers an intercepting GET handler that runs before the handler registered via + * {@link #onGetRequest}, enabling opt-in pagination for collection GETs. + * + */ + public RequestHandlersBuilder withPaginatedGetRequest( + final CheckedFunction, IOException> mapper + ) { + Objects.requireNonNull(mapper, "withPaginatedGetRequest handler can't be null"); + // Capture the legacy handler that was registered by onGetRequest so we can + // fall through to it when the override returns null. + final RequestHandler legacyHandler = requestHandlers.get(RestRequest.Method.GET); + add(RestRequest.Method.GET, (channel, request, client) -> { + final ValidationResult result = mapper.apply(request); + if (result != null) { + result.valid(toXContent -> ok(channel, toXContent)) + .error((status, toXContent) -> response(channel, status, toXContent)); + } else { + legacyHandler.handle(channel, request, client); + } + }); + return this; + } + public RequestHandlersBuilder onJsonContentGetRequest( final CheckedFunction, IOException> mapper ) { diff --git a/src/main/java/org/opensearch/security/dlic/rest/api/pagination/PaginationCursor.java b/src/main/java/org/opensearch/security/dlic/rest/api/pagination/PaginationCursor.java new file mode 100644 index 0000000000..76bfda4ff3 --- /dev/null +++ b/src/main/java/org/opensearch/security/dlic/rest/api/pagination/PaginationCursor.java @@ -0,0 +1,113 @@ +/* + * 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.nio.charset.StandardCharsets; +import java.util.Base64; + +import org.opensearch.core.rest.RestStatus; +import org.opensearch.security.dlic.rest.validation.ValidationResult; +import org.opensearch.security.securityconf.impl.CType; + +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.node.ObjectNode; + +import static org.opensearch.security.dlic.rest.api.Responses.badRequestMessage; + +/** + * Pagination cursor for Security API collection GETs. + * + */ +public final class PaginationCursor { + + static final ObjectMapper MAPPER = new ObjectMapper(); + + private static final String FIELD_CTYPE = "ctype"; + private static final String FIELD_SORT = "sort"; + private static final String FIELD_LAST_KEY = "last_key"; + + /** The opaque Base64url string received from the client as {@code next_token}. */ + public final String token; + + /** The last entity name included on the previous page — the resume position. */ + public final String lastKey; + + private PaginationCursor(final String token, final String lastKey) { + this.token = token; + this.lastKey = lastKey; + } + + /** + * Encodes a new cursor capturing the current traversal position. + * + * @param ctype configuration type of the current endpoint + * @param sort sort direction in use ({@code "asc"} or {@code "desc"}) + * @param lastKey last entity name returned on this page + * @return a new {@link PaginationCursor} + */ + public static PaginationCursor encode(final CType ctype, final String sort, final String lastKey) { + final ObjectNode node = MAPPER.createObjectNode(); + node.put(FIELD_CTYPE, ctype.toLCString()); + node.put(FIELD_SORT, sort); + node.put(FIELD_LAST_KEY, lastKey); + final String json = MAPPER.writeValueAsString(node); + final String encoded = Base64.getUrlEncoder().withoutPadding().encodeToString(json.getBytes(StandardCharsets.UTF_8)); + return new PaginationCursor(encoded, lastKey); + } + + /** + * Decodes and validates a cursor supplied by the client. + * + * @param encoded raw {@code next_token} value from the request + * @param ctype expected configuration type + * @param sort expected sort direction + * @return a successful {@link ValidationResult} containing the decoded {@link PaginationCursor}, + * or a {@code 400 Bad Request} error + */ + public static ValidationResult decode(final String encoded, final CType ctype, final String sort) { + try { + final byte[] bytes = Base64.getUrlDecoder().decode(encoded); + final JsonNode node = MAPPER.readTree(new String(bytes, StandardCharsets.UTF_8)); + + if (!node.isObject()) { + return ValidationResult.error(RestStatus.BAD_REQUEST, badRequestMessage("Invalid next_token: not a JSON object.")); + } + if (!node.has(FIELD_CTYPE) || !node.has(FIELD_SORT) || !node.has(FIELD_LAST_KEY)) { + return ValidationResult.error(RestStatus.BAD_REQUEST, badRequestMessage("Invalid next_token: missing required fields.")); + } + + final String tokenCtype = node.get(FIELD_CTYPE).asText(); + final String tokenSort = node.get(FIELD_SORT).asText(); + final String tokenLastKey = node.get(FIELD_LAST_KEY).asText(); + + if (tokenCtype.isEmpty() || tokenSort.isEmpty() || tokenLastKey.isEmpty()) { + return ValidationResult.error(RestStatus.BAD_REQUEST, badRequestMessage("Invalid next_token: missing required fields.")); + } + if (!ctype.toLCString().equals(tokenCtype)) { + return ValidationResult.error( + RestStatus.BAD_REQUEST, + badRequestMessage("Invalid next_token: token was issued for a different endpoint.") + ); + } + if (!sort.equals(tokenSort)) { + return ValidationResult.error( + RestStatus.BAD_REQUEST, + badRequestMessage("Invalid next_token: sort direction does not match token.") + ); + } + return ValidationResult.success(new PaginationCursor(encoded, tokenLastKey)); + } catch (Exception e) { + return ValidationResult.error(RestStatus.BAD_REQUEST, badRequestMessage("Invalid next_token: " + e.getMessage())); + } + } +} diff --git a/src/main/java/org/opensearch/security/dlic/rest/api/pagination/PaginationParams.java b/src/main/java/org/opensearch/security/dlic/rest/api/pagination/PaginationParams.java new file mode 100644 index 0000000000..d72fa3d752 --- /dev/null +++ b/src/main/java/org/opensearch/security/dlic/rest/api/pagination/PaginationParams.java @@ -0,0 +1,48 @@ +/* + * 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 org.opensearch.action.pagination.PageParams; + +/** + * Parsed and validated pagination parameters extracted from an incoming request. + */ +public final class PaginationParams { + + public static final String SORT_ASC = PageParams.PARAM_ASC_SORT_VALUE; + public static final String SORT_DESC = PageParams.PARAM_DESC_SORT_VALUE; + public static final int DEFAULT_SIZE = 100; + + /** Page size requested by the caller. */ + public final int size; + + /** Sort direction: {@code "asc"} or {@code "desc"}. */ + public final String sort; + + /** + * cursor from the previous response. + */ + public final String nextToken; + + public PaginationParams(final int size, final String sort, final String nextToken) { + this.size = size; + this.sort = sort; + this.nextToken = nextToken; + } + + /** + * Returns {@code true} when a cursor was supplied, meaning this is a continuation page. + */ + public boolean hasCursor() { + return nextToken != null; + } +} diff --git a/src/main/java/org/opensearch/security/dlic/rest/api/pagination/PaginationRequestParser.java b/src/main/java/org/opensearch/security/dlic/rest/api/pagination/PaginationRequestParser.java new file mode 100644 index 0000000000..50bf9fbb66 --- /dev/null +++ b/src/main/java/org/opensearch/security/dlic/rest/api/pagination/PaginationRequestParser.java @@ -0,0 +1,86 @@ +/* + * 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 org.opensearch.action.pagination.PageParams; +import org.opensearch.action.pagination.PageToken; +import org.opensearch.core.rest.RestStatus; +import org.opensearch.rest.RestRequest; +import org.opensearch.security.dlic.rest.validation.ValidationResult; + +import static org.opensearch.security.dlic.rest.api.Responses.badRequestMessage; + +/** + * Parses and validates pagination query parameters from an incoming {@link RestRequest}. + */ +public final class PaginationRequestParser { + + public static final String PARAM_SIZE = PageParams.PARAM_SIZE; + public static final String PARAM_SORT = PageParams.PARAM_SORT; + public static final String PARAM_NEXT_TOKEN = PageParams.PARAM_NEXT_TOKEN; + public static final String RESPONSE_NEXT_TOKEN_KEY = PageToken.PAGINATED_RESPONSE_NEXT_TOKEN_KEY; + + private PaginationRequestParser() {} + + public static boolean isPaginationRequested(final RestRequest request) { + return request.hasParam(PARAM_SIZE) || request.hasParam(PARAM_SORT) || request.hasParam(PARAM_NEXT_TOKEN); + } + + /** + * Parses and validates pagination parameters from the request. + * + * @param request the incoming REST request + * @return a successful {@link ValidationResult} with a {@link PaginationParams} instance, + * or a {@code 400 Bad Request} error describing what is wrong + */ + public static ValidationResult parse(final RestRequest request) { + final String sizeParam = request.param(PARAM_SIZE); + final String sortParam = request.param(PARAM_SORT); + final String tokenParam = request.param(PARAM_NEXT_TOKEN); + + // Validates sort param + final String sort; + if (sortParam == null) { + sort = PaginationParams.SORT_ASC; + } else if (PaginationParams.SORT_ASC.equals(sortParam) || PaginationParams.SORT_DESC.equals(sortParam)) { + sort = sortParam; + } else { + return ValidationResult.error( + RestStatus.BAD_REQUEST, + badRequestMessage("Invalid sort parameter '" + sortParam + "'. Must be 'asc' or 'desc'.") + ); + } + + // Validates size param + final int size; + if (sizeParam == null) { + size = PaginationParams.DEFAULT_SIZE; + } else { + try { + size = Integer.parseInt(sizeParam); + } catch (NumberFormatException e) { + return ValidationResult.error( + RestStatus.BAD_REQUEST, + badRequestMessage("Invalid size parameter '" + sizeParam + "'. Must be a positive integer.") + ); + } + if (size <= 0) { + return ValidationResult.error( + RestStatus.BAD_REQUEST, + badRequestMessage("Invalid size parameter '" + size + "'. Must be a positive integer.") + ); + } + } + + return ValidationResult.success(new PaginationParams(size, sort, tokenParam)); + } +} diff --git a/src/main/java/org/opensearch/security/dlic/rest/api/pagination/PaginationResult.java b/src/main/java/org/opensearch/security/dlic/rest/api/pagination/PaginationResult.java new file mode 100644 index 0000000000..c1bbd98d36 --- /dev/null +++ b/src/main/java/org/opensearch/security/dlic/rest/api/pagination/PaginationResult.java @@ -0,0 +1,78 @@ +/* + * 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.util.Map; + +import org.opensearch.core.xcontent.ToXContent; +import org.opensearch.core.xcontent.XContentBuilder; +import org.opensearch.security.DefaultObjectMapper; + +/** + * A single page of a paginated collection response. + * + * @param the configuration entry type + */ +public final class PaginationResult implements ToXContent { + + private static final String FIELD_NEXT_TOKEN = PaginationRequestParser.RESPONSE_NEXT_TOKEN_KEY; + public final String resourceKey; + public final Map entries; + public final String nextToken; + + private PaginationResult(final String resourceKey, final Map entries, final String nextToken) { + this.resourceKey = resourceKey; + this.entries = entries; + this.nextToken = nextToken; + } + + /** + * Creates a non-terminal page result (there are more pages to follow). + */ + public static PaginationResult of(final String resourceKey, final Map entries, final PaginationCursor nextCursor) { + return new PaginationResult<>(resourceKey, entries, nextCursor.token); + } + + /** + * Creates a terminal page result ({@code next_token} will be {@code null}). + */ + public static PaginationResult last(final String resourceKey, final Map entries) { + return new PaginationResult<>(resourceKey, entries, null); + } + + @Override + public XContentBuilder toXContent(final XContentBuilder builder, final Params params) throws IOException { + builder.startObject(); + + if (nextToken == null) { + builder.nullField(FIELD_NEXT_TOKEN); + } else { + builder.field(FIELD_NEXT_TOKEN, nextToken); + } + + @SuppressWarnings("unchecked") + final Map serialisable = DefaultObjectMapper.readValue( + DefaultObjectMapper.writeValueAsString(entries, false), + Map.class + ); + builder.field(resourceKey, serialisable); + + builder.endObject(); + return builder; + } + + @Override + public boolean isFragment() { + return false; + } +} diff --git a/src/main/java/org/opensearch/security/dlic/rest/api/pagination/Paginator.java b/src/main/java/org/opensearch/security/dlic/rest/api/pagination/Paginator.java new file mode 100644 index 0000000000..177f218f21 --- /dev/null +++ b/src/main/java/org/opensearch/security/dlic/rest/api/pagination/Paginator.java @@ -0,0 +1,94 @@ +/* + * 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.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.opensearch.security.securityconf.impl.CType; + +/** + * Stateless utility that applies high-performance cursor-based pagination to a fully-processed + * (authorized, redacted, filtered) Security configuration entry map. + * + */ +public final class Paginator { + + private Paginator() {} + + /** + * Paginates the entry map. + * + * @param allEntries fully-processed, caller-visible entries (key = entity name) + * @param params validated pagination parameters + * @param cursor pre-validated cursor from a prior page + * @param ctype configuration type + * @return a {@link PaginationResult} + */ + public static PaginationResult paginate( + final Map allEntries, + final PaginationParams params, + final PaginationCursor cursor, + final CType ctype + ) { + Objects.requireNonNull(allEntries, "allEntries must not be null"); + Objects.requireNonNull(params, "params must not be null"); + Objects.requireNonNull(ctype, "ctype must not be null"); + + final String resourceKey = ctype.toLCString(); + + if (allEntries.isEmpty()) { + return PaginationResult.last(resourceKey, Map.of()); + } + + final boolean isDesc = PaginationParams.SORT_DESC.equals(params.sort); + final String lastKey = (cursor != null) ? cursor.lastKey : null; + + // Filter first + Stream> entryStream = allEntries.entrySet().stream(); + if (lastKey != null && !lastKey.isEmpty()) { + entryStream = entryStream.filter(entry -> { + final int cmp = entry.getKey().compareTo(lastKey); + return isDesc ? cmp < 0 : cmp > 0; + }); + } + + // Sort the filtered items + Comparator> comparator = Map.Entry.comparingByKey(); + if (isDesc) { + comparator = comparator.reversed(); + } + final int targetSize = params.size; + final List> candidatePage = entryStream.sorted(comparator).limit(targetSize + 1L).collect(Collectors.toList()); + + final boolean hasNextPage = candidatePage.size() > targetSize; + final int pageSize = hasNextPage ? targetSize : candidatePage.size(); + + final Map pageMap = new LinkedHashMap<>((int) Math.ceil(pageSize / 0.75f)); + for (int i = 0; i < pageSize; i++) { + final Map.Entry entry = candidatePage.get(i); + pageMap.put(entry.getKey(), entry.getValue()); + } + + if (hasNextPage) { + final String newLastKey = candidatePage.get(pageSize - 1).getKey(); + return PaginationResult.of(resourceKey, pageMap, PaginationCursor.encode(ctype, params.sort, newLastKey)); + } + + return PaginationResult.last(resourceKey, pageMap); + } +} From 0f2953d71eb71b85f5487fbf6c6fbec3b50b0b1d Mon Sep 17 00:00:00 2001 From: Vishnutheep B Date: Sat, 8 Aug 2026 23:42:10 +0530 Subject: [PATCH 2/8] Add edge cases to improve pagination api Signed-off-by: Vishnutheep B --- .../dlic/rest/api/AbstractApiAction.java | 5 ++++- .../rest/api/pagination/PaginationCursor.java | 20 ++++++++++++++----- .../rest/api/pagination/PaginationParams.java | 1 + .../pagination/PaginationRequestParser.java | 4 ++-- 4 files changed, 22 insertions(+), 8 deletions(-) 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 3b09942d64..f2702a7cb0 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 @@ -179,7 +179,10 @@ protected ValidationResult processPaginatedGetRequest(final RestRequ badRequestMessage("Pagination parameters are not supported for single-entity GET requests.") ); } - return loadConfiguration(getConfigType(), true, true).map(configuration -> { + return loadConfiguration(getConfigType(), true, true).map( + configuration -> ValidationResult.success(SecurityConfiguration.of(null, configuration)) + ).map(endpointValidator::onConfigLoad).map(securityConfiguration -> { + final SecurityDynamicConfiguration configuration = securityConfiguration.configuration(); if (params.hasCursor()) { return PaginationCursor.decode(params.nextToken, getConfigType(), params.sort) .map(cursor -> buildPaginatedPage(configuration, params, cursor)); diff --git a/src/main/java/org/opensearch/security/dlic/rest/api/pagination/PaginationCursor.java b/src/main/java/org/opensearch/security/dlic/rest/api/pagination/PaginationCursor.java index 76bfda4ff3..50ddb536fa 100644 --- a/src/main/java/org/opensearch/security/dlic/rest/api/pagination/PaginationCursor.java +++ b/src/main/java/org/opensearch/security/dlic/rest/api/pagination/PaginationCursor.java @@ -13,6 +13,10 @@ import java.nio.charset.StandardCharsets; import java.util.Base64; +import java.util.Objects; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; import org.opensearch.core.rest.RestStatus; import org.opensearch.security.dlic.rest.validation.ValidationResult; @@ -26,10 +30,10 @@ /** * Pagination cursor for Security API collection GETs. - * */ public final class PaginationCursor { + private static final Logger LOGGER = LogManager.getLogger(PaginationCursor.class); static final ObjectMapper MAPPER = new ObjectMapper(); private static final String FIELD_CTYPE = "ctype"; @@ -56,13 +60,18 @@ private PaginationCursor(final String token, final String lastKey) { * @return a new {@link PaginationCursor} */ public static PaginationCursor encode(final CType ctype, final String sort, final String lastKey) { + Objects.requireNonNull(lastKey, "lastKey must not be null"); final ObjectNode node = MAPPER.createObjectNode(); node.put(FIELD_CTYPE, ctype.toLCString()); node.put(FIELD_SORT, sort); node.put(FIELD_LAST_KEY, lastKey); - final String json = MAPPER.writeValueAsString(node); - final String encoded = Base64.getUrlEncoder().withoutPadding().encodeToString(json.getBytes(StandardCharsets.UTF_8)); - return new PaginationCursor(encoded, lastKey); + try { + final String json = MAPPER.writeValueAsString(node); + final String encoded = Base64.getUrlEncoder().withoutPadding().encodeToString(json.getBytes(StandardCharsets.UTF_8)); + return new PaginationCursor(encoded, lastKey); + } catch (Exception e) { + throw new IllegalStateException("Failed to encode pagination cursor", e); + } } /** @@ -107,7 +116,8 @@ public static ValidationResult decode(final String encoded, fi } return ValidationResult.success(new PaginationCursor(encoded, tokenLastKey)); } catch (Exception e) { - return ValidationResult.error(RestStatus.BAD_REQUEST, badRequestMessage("Invalid next_token: " + e.getMessage())); + LOGGER.debug("Failed to decode pagination cursor", e); + return ValidationResult.error(RestStatus.BAD_REQUEST, badRequestMessage("Invalid next_token.")); } } } diff --git a/src/main/java/org/opensearch/security/dlic/rest/api/pagination/PaginationParams.java b/src/main/java/org/opensearch/security/dlic/rest/api/pagination/PaginationParams.java index d72fa3d752..8c2c6882e7 100644 --- a/src/main/java/org/opensearch/security/dlic/rest/api/pagination/PaginationParams.java +++ b/src/main/java/org/opensearch/security/dlic/rest/api/pagination/PaginationParams.java @@ -21,6 +21,7 @@ public final class PaginationParams { public static final String SORT_ASC = PageParams.PARAM_ASC_SORT_VALUE; public static final String SORT_DESC = PageParams.PARAM_DESC_SORT_VALUE; public static final int DEFAULT_SIZE = 100; + public static final int MAX_SIZE = 1000; /** Page size requested by the caller. */ public final int size; diff --git a/src/main/java/org/opensearch/security/dlic/rest/api/pagination/PaginationRequestParser.java b/src/main/java/org/opensearch/security/dlic/rest/api/pagination/PaginationRequestParser.java index 50bf9fbb66..0c6208b21b 100644 --- a/src/main/java/org/opensearch/security/dlic/rest/api/pagination/PaginationRequestParser.java +++ b/src/main/java/org/opensearch/security/dlic/rest/api/pagination/PaginationRequestParser.java @@ -73,10 +73,10 @@ public static ValidationResult parse(final RestRequest request badRequestMessage("Invalid size parameter '" + sizeParam + "'. Must be a positive integer.") ); } - if (size <= 0) { + if (size <= 0 || size > PaginationParams.MAX_SIZE) { return ValidationResult.error( RestStatus.BAD_REQUEST, - badRequestMessage("Invalid size parameter '" + size + "'. Must be a positive integer.") + badRequestMessage("Invalid size parameter '" + size + "'. Must be between 1 and " + PaginationParams.MAX_SIZE + ".") ); } } From 5ff0dd4aaf4fa7ee134ae91b1484d31dc67cf858 Mon Sep 17 00:00:00 2001 From: Vishnutheep B Date: Sun, 9 Aug 2026 00:26:28 +0530 Subject: [PATCH 3/8] Add unit tests for pagination support Signed-off-by: Vishnutheep B --- .../api/pagination/PaginationCursorTest.java | 134 +++++++++++ .../PaginationRequestParserTest.java | 175 ++++++++++++++ .../api/pagination/PaginationResultTest.java | 104 +++++++++ .../rest/api/pagination/PaginatorTest.java | 219 ++++++++++++++++++ 4 files changed, 632 insertions(+) create mode 100644 src/test/java/org/opensearch/security/dlic/rest/api/pagination/PaginationCursorTest.java create mode 100644 src/test/java/org/opensearch/security/dlic/rest/api/pagination/PaginationRequestParserTest.java create mode 100644 src/test/java/org/opensearch/security/dlic/rest/api/pagination/PaginationResultTest.java create mode 100644 src/test/java/org/opensearch/security/dlic/rest/api/pagination/PaginatorTest.java diff --git a/src/test/java/org/opensearch/security/dlic/rest/api/pagination/PaginationCursorTest.java b/src/test/java/org/opensearch/security/dlic/rest/api/pagination/PaginationCursorTest.java new file mode 100644 index 0000000000..c319451fc3 --- /dev/null +++ b/src/test/java/org/opensearch/security/dlic/rest/api/pagination/PaginationCursorTest.java @@ -0,0 +1,134 @@ +/* + * 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.nio.charset.StandardCharsets; +import java.util.Base64; + +import org.junit.Test; + +import org.opensearch.core.rest.RestStatus; +import org.opensearch.security.securityconf.impl.CType; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.not; +import static org.hamcrest.Matchers.nullValue; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class PaginationCursorTest { + + @Test + public void encodeProducesTokenWithLastKey() throws Exception { + final var cursor = PaginationCursor.encode(CType.ROLES, PaginationParams.SORT_ASC, "role_a"); + + assertThat(cursor.lastKey, is("role_a")); + assertThat(cursor.token, not(nullValue())); + } + + @Test + public void encodeRejectsNullLastKey() { + assertThrows(NullPointerException.class, () -> PaginationCursor.encode(CType.ROLES, PaginationParams.SORT_ASC, null)); + } + + @Test + public void roundTripAscending() throws Exception { + final var encoded = PaginationCursor.encode(CType.ROLES, PaginationParams.SORT_ASC, "role_z"); + final PaginationCursor[] decoded = new PaginationCursor[1]; + + final var result = PaginationCursor.decode(encoded.token, CType.ROLES, PaginationParams.SORT_ASC); + assertTrue(result.isValid()); + result.valid(c -> decoded[0] = c); + + assertThat(decoded[0].lastKey, is("role_z")); + assertThat(decoded[0].token, is(encoded.token)); + } + + @Test + public void roundTripDescending() throws Exception { + final var encoded = PaginationCursor.encode(CType.INTERNALUSERS, PaginationParams.SORT_DESC, "user_m"); + final PaginationCursor[] decoded = new PaginationCursor[1]; + + final var result = PaginationCursor.decode(encoded.token, CType.INTERNALUSERS, PaginationParams.SORT_DESC); + assertTrue(result.isValid()); + result.valid(c -> decoded[0] = c); + + assertThat(decoded[0].lastKey, is("user_m")); + } + + @Test + public void decodeRejectsInvalidToken() { + final var result = PaginationCursor.decode("not-base64!!!", CType.ROLES, PaginationParams.SORT_ASC); + + assertFalse(result.isValid()); + assertThat(result.status(), is(RestStatus.BAD_REQUEST)); + } + + @Test + public void decodeRejectsNonJsonPayload() { + final var encoded = Base64.getUrlEncoder().withoutPadding().encodeToString("hello world".getBytes(StandardCharsets.UTF_8)); + + final var result = PaginationCursor.decode(encoded, CType.ROLES, PaginationParams.SORT_ASC); + + assertFalse(result.isValid()); + assertThat(result.status(), is(RestStatus.BAD_REQUEST)); + } + + @Test + public void decodeRejectsMissingField() { + // JSON object but last_key absent + final var encoded = Base64.getUrlEncoder() + .withoutPadding() + .encodeToString("{\"ctype\":\"roles\",\"sort\":\"asc\"}".getBytes(StandardCharsets.UTF_8)); + + final var result = PaginationCursor.decode(encoded, CType.ROLES, PaginationParams.SORT_ASC); + + assertFalse(result.isValid()); + assertThat(result.status(), is(RestStatus.BAD_REQUEST)); + } + + @Test + public void decodeRejectsEmptyFieldValue() { + // last_key is present but blank + final var encoded = Base64.getUrlEncoder() + .withoutPadding() + .encodeToString("{\"ctype\":\"roles\",\"sort\":\"asc\",\"last_key\":\"\"}".getBytes(StandardCharsets.UTF_8)); + + final var result = PaginationCursor.decode(encoded, CType.ROLES, PaginationParams.SORT_ASC); + + assertFalse(result.isValid()); + assertThat(result.status(), is(RestStatus.BAD_REQUEST)); + } + + @Test + public void decodeRejectsCrossEndpointToken() { + // cursor issued for ROLES but presented to TENANTS + final var cursor = PaginationCursor.encode(CType.ROLES, PaginationParams.SORT_ASC, "role_a"); + + final var result = PaginationCursor.decode(cursor.token, CType.TENANTS, PaginationParams.SORT_ASC); + + assertFalse(result.isValid()); + assertThat(result.status(), is(RestStatus.BAD_REQUEST)); + } + + @Test + public void decodeRejectsSortDirectionMismatch() { + final var cursor = PaginationCursor.encode(CType.ROLES, PaginationParams.SORT_ASC, "role_a"); + + final var result = PaginationCursor.decode(cursor.token, CType.ROLES, PaginationParams.SORT_DESC); + + assertFalse(result.isValid()); + assertThat(result.status(), is(RestStatus.BAD_REQUEST)); + } +} diff --git a/src/test/java/org/opensearch/security/dlic/rest/api/pagination/PaginationRequestParserTest.java b/src/test/java/org/opensearch/security/dlic/rest/api/pagination/PaginationRequestParserTest.java new file mode 100644 index 0000000000..81cc1eb7eb --- /dev/null +++ b/src/test/java/org/opensearch/security/dlic/rest/api/pagination/PaginationRequestParserTest.java @@ -0,0 +1,175 @@ +/* + * 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.util.Map; + +import org.junit.Test; + +import org.opensearch.core.rest.RestStatus; +import org.opensearch.security.securityconf.impl.CType; +import org.opensearch.security.util.FakeRestRequest; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +public class PaginationRequestParserTest { + + @Test + public void isPaginationRequestedFalseWhenNoParams() { + final var request = FakeRestRequest.builder().build(); + + assertFalse(PaginationRequestParser.isPaginationRequested(request)); + } + + @Test + public void isPaginationRequestedTrueForSizeParam() { + final var request = FakeRestRequest.builder().withParams(Map.of(PaginationRequestParser.PARAM_SIZE, "10")).build(); + + assertTrue(PaginationRequestParser.isPaginationRequested(request)); + } + + @Test + public void isPaginationRequestedTrueForSortParam() { + final var request = FakeRestRequest.builder().withParams(Map.of(PaginationRequestParser.PARAM_SORT, "asc")).build(); + + assertTrue(PaginationRequestParser.isPaginationRequested(request)); + } + + @Test + public void isPaginationRequestedTrueForNextTokenParam() { + final var request = FakeRestRequest.builder().withParams(Map.of(PaginationRequestParser.PARAM_NEXT_TOKEN, "sometoken")).build(); + + assertTrue(PaginationRequestParser.isPaginationRequested(request)); + } + + @Test + public void parseWithNoParamsUsesDefaults() throws Exception { + final var request = FakeRestRequest.builder().withParams(Map.of(PaginationRequestParser.PARAM_SIZE, "50")).build(); + final PaginationParams[] params = new PaginationParams[1]; + + final var result = PaginationRequestParser.parse(request); + assertTrue(result.isValid()); + result.valid(p -> params[0] = p); + + assertThat(params[0].size, is(50)); + assertThat(params[0].sort, is(PaginationParams.SORT_ASC)); + assertNull(params[0].nextToken); + } + + @Test + public void parseDefaultsSizeWhenOnlySortProvided() throws Exception { + final var request = FakeRestRequest.builder().withParams(Map.of(PaginationRequestParser.PARAM_SORT, "desc")).build(); + final PaginationParams[] params = new PaginationParams[1]; + + final var result = PaginationRequestParser.parse(request); + assertTrue(result.isValid()); + result.valid(p -> params[0] = p); + + assertThat(params[0].size, is(PaginationParams.DEFAULT_SIZE)); + assertThat(params[0].sort, is(PaginationParams.SORT_DESC)); + } + + @Test + public void parsePreservesNextToken() throws Exception { + final var token = PaginationCursor.encode(CType.ROLES, PaginationParams.SORT_ASC, "role_a").token; + final var request = FakeRestRequest.builder() + .withParams(Map.of(PaginationRequestParser.PARAM_SIZE, "5", PaginationRequestParser.PARAM_NEXT_TOKEN, token)) + .build(); + final PaginationParams[] params = new PaginationParams[1]; + + PaginationRequestParser.parse(request).valid(p -> params[0] = p); + + assertThat(params[0].nextToken, is(token)); + assertTrue(params[0].hasCursor()); + } + + @Test + public void parseRejectsZeroSize() { + final var request = FakeRestRequest.builder().withParams(Map.of(PaginationRequestParser.PARAM_SIZE, "0")).build(); + + final var result = PaginationRequestParser.parse(request); + + assertFalse(result.isValid()); + assertThat(result.status(), is(RestStatus.BAD_REQUEST)); + } + + @Test + public void parseRejectsNegativeSize() { + final var request = FakeRestRequest.builder().withParams(Map.of(PaginationRequestParser.PARAM_SIZE, "-1")).build(); + + final var result = PaginationRequestParser.parse(request); + + assertFalse(result.isValid()); + assertThat(result.status(), is(RestStatus.BAD_REQUEST)); + } + + @Test + public void parseRejectsSizeAboveMax() { + final var request = FakeRestRequest.builder() + .withParams(Map.of(PaginationRequestParser.PARAM_SIZE, String.valueOf(PaginationParams.MAX_SIZE + 1))) + .build(); + + final var result = PaginationRequestParser.parse(request); + + assertFalse(result.isValid()); + assertThat(result.status(), is(RestStatus.BAD_REQUEST)); + } + + @Test + public void parseAcceptsMaxSize() throws Exception { + final var request = FakeRestRequest.builder() + .withParams(Map.of(PaginationRequestParser.PARAM_SIZE, String.valueOf(PaginationParams.MAX_SIZE))) + .build(); + + final var result = PaginationRequestParser.parse(request); + + assertTrue(result.isValid()); + } + + @Test + public void parseRejectsNonIntegerSize() { + final var request = FakeRestRequest.builder().withParams(Map.of(PaginationRequestParser.PARAM_SIZE, "abc")).build(); + + final var result = PaginationRequestParser.parse(request); + + assertFalse(result.isValid()); + assertThat(result.status(), is(RestStatus.BAD_REQUEST)); + } + + @Test + public void parseAcceptsAsc() throws Exception { + final var request = FakeRestRequest.builder().withParams(Map.of(PaginationRequestParser.PARAM_SORT, "asc")).build(); + + assertTrue(PaginationRequestParser.parse(request).isValid()); + } + + @Test + public void parseAcceptsDesc() throws Exception { + final var request = FakeRestRequest.builder().withParams(Map.of(PaginationRequestParser.PARAM_SORT, "desc")).build(); + + assertTrue(PaginationRequestParser.parse(request).isValid()); + } + + @Test + public void parseRejectsInvalidSort() { + final var request = FakeRestRequest.builder().withParams(Map.of(PaginationRequestParser.PARAM_SORT, "random")).build(); + + final var result = PaginationRequestParser.parse(request); + + assertFalse(result.isValid()); + assertThat(result.status(), is(RestStatus.BAD_REQUEST)); + } +} diff --git a/src/test/java/org/opensearch/security/dlic/rest/api/pagination/PaginationResultTest.java b/src/test/java/org/opensearch/security/dlic/rest/api/pagination/PaginationResultTest.java new file mode 100644 index 0000000000..634beebb0a --- /dev/null +++ b/src/test/java/org/opensearch/security/dlic/rest/api/pagination/PaginationResultTest.java @@ -0,0 +1,104 @@ +/* + * 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.util.Map; + +import org.junit.Test; + +import org.opensearch.common.xcontent.XContentFactory; +import org.opensearch.core.xcontent.ToXContent; +import org.opensearch.security.DefaultObjectMapper; +import org.opensearch.security.securityconf.impl.CType; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.nullValue; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +public class PaginationResultTest { + + private static tools.jackson.databind.JsonNode toJson(final ToXContent result) throws IOException { + try (final var builder = XContentFactory.jsonBuilder()) { + result.toXContent(builder, ToXContent.EMPTY_PARAMS); + return DefaultObjectMapper.readTree(builder.toString()); + } + } + + @Test + public void terminalPageHasNullNextTokenInJson() throws IOException { + final var result = PaginationResult.last("roles", Map.of("role_a", "v")); + + final var json = toJson(result); + + assertTrue("next_token must be present as null", json.has("next_token")); + assertTrue("next_token must be JSON null", json.get("next_token").isNull()); + } + + @Test + public void terminalPageContainsResourceKey() throws IOException { + final var result = PaginationResult.last("roles", Map.of("role_a", "v")); + + final var json = toJson(result); + + assertTrue("resource key 'roles' must be present", json.has("roles")); + } + + @Test + public void terminalPageEntriesAreSerialized() throws IOException { + final var result = PaginationResult.last("roles", Map.of("role_a", "val_a")); + + final var json = toJson(result); + + assertNotNull(json.get("roles").get("role_a")); + } + + @Test + public void isFragmentReturnsFalse() { + final var result = PaginationResult.last("roles", Map.of()); + assertFalse(result.isFragment()); + } + + @Test + public void nonTerminalPageContainsNextToken() throws IOException { + final var cursor = PaginationCursor.encode(CType.ROLES, PaginationParams.SORT_ASC, "role_a"); + final var result = PaginationResult.of("roles", Map.of("role_a", "v"), cursor); + + final var json = toJson(result); + + assertFalse("next_token must not be null for non-terminal page", json.get("next_token").isNull()); + assertThat(json.get("next_token").asText(), is(cursor.token)); + } + + @Test + public void nonTerminalPageResourceKeyMatchesCType() throws IOException { + final var cursor = PaginationCursor.encode(CType.ROLES, PaginationParams.SORT_ASC, "role_a"); + final var result = PaginationResult.of("roles", Map.of("role_a", "v"), cursor); + + final var json = toJson(result); + + assertTrue(json.has("roles")); + } + + @Test + public void emptyEntriesProducesEmptyObject() throws IOException { + final var result = PaginationResult.last("roles", Map.of()); + + final var json = toJson(result); + + assertThat(json.get("roles").size(), is(0)); + assertThat(result.nextToken, nullValue()); + } +} diff --git a/src/test/java/org/opensearch/security/dlic/rest/api/pagination/PaginatorTest.java b/src/test/java/org/opensearch/security/dlic/rest/api/pagination/PaginatorTest.java new file mode 100644 index 0000000000..52654d53b2 --- /dev/null +++ b/src/test/java/org/opensearch/security/dlic/rest/api/pagination/PaginatorTest.java @@ -0,0 +1,219 @@ +/* + * 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.util.LinkedHashMap; +import java.util.Map; + +import org.junit.Test; + +import org.opensearch.security.securityconf.impl.CType; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.is; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class PaginatorTest { + + /** Builds an insertion-ordered map: a→val, b→val, … up to {@code count} entries. */ + private static Map entries(int count) { + final var map = new LinkedHashMap(); + for (int i = 0; i < count; i++) { + final String key = String.format("entry_%02d", i); + map.put(key, "v" + i); + } + return map; + } + + private static PaginationParams ascParams(int size) { + return new PaginationParams(size, PaginationParams.SORT_ASC, null); + } + + private static PaginationParams descParams(int size) { + return new PaginationParams(size, PaginationParams.SORT_DESC, null); + } + + @Test + public void emptyCollectionReturnsEmptyTerminalPage() { + final var result = Paginator.paginate(Map.of(), ascParams(10), null, CType.ROLES); + + assertTrue(result.entries.isEmpty()); + assertNull(result.nextToken); + } + + @Test + public void allEntriesFitOnOnePage() { + final var result = Paginator.paginate(entries(3), ascParams(10), null, CType.ROLES); + + assertThat(result.entries.size(), is(3)); + assertNull("terminal page must have null nextToken", result.nextToken); + } + + @Test + public void resourceKeyMatchesCType() { + final var result = Paginator.paginate(entries(1), ascParams(5), null, CType.ROLES); + + assertThat(result.resourceKey, is("roles")); + } + + @Test + public void firstPageOfTwoHasNextToken() { + final var result = Paginator.paginate(entries(5), ascParams(2), null, CType.ROLES); + + assertThat(result.entries.size(), is(2)); + assertNotNull("non-terminal page must have a nextToken", result.nextToken); + assertThat(result.entries.keySet(), contains("entry_00", "entry_01")); + } + + @Test + public void secondPageContinuesFromCursor() throws Exception { + final var all = entries(5); + final var page1 = Paginator.paginate(all, ascParams(2), null, CType.ROLES); + + // decode cursor from page1 + final PaginationCursor[] cursor = new PaginationCursor[1]; + PaginationCursor.decode(page1.nextToken, CType.ROLES, PaginationParams.SORT_ASC).valid(c -> cursor[0] = c); + + final var page2 = Paginator.paginate(all, ascParams(2), cursor[0], CType.ROLES); + + assertThat(page2.entries.keySet(), contains("entry_02", "entry_03")); + assertNotNull(page2.nextToken); + } + + @Test + public void lastPageHasNullNextToken() throws Exception { + final var all = entries(5); + // page 1 (size=2) → page 2 (size=2) → page 3 (size=2) must be terminal + var page = Paginator.paginate(all, ascParams(2), null, CType.ROLES); + final PaginationCursor[] cursor = new PaginationCursor[1]; + PaginationCursor.decode(page.nextToken, CType.ROLES, PaginationParams.SORT_ASC).valid(c -> cursor[0] = c); + + page = Paginator.paginate(all, ascParams(2), cursor[0], CType.ROLES); + PaginationCursor.decode(page.nextToken, CType.ROLES, PaginationParams.SORT_ASC).valid(c -> cursor[0] = c); + + page = Paginator.paginate(all, ascParams(2), cursor[0], CType.ROLES); + + assertThat(page.entries.keySet(), contains("entry_04")); + assertNull("last page must have null nextToken", page.nextToken); + } + + @Test + public void fullTraversalCoversAllEntriesExactlyOnce() throws Exception { + final var all = entries(7); + final var seen = new LinkedHashMap(); + PaginationCursor cursor = null; + + do { + final var params = new PaginationParams(3, PaginationParams.SORT_ASC, null); + final var page = Paginator.paginate(all, params, cursor, CType.ROLES); + seen.putAll(page.entries); + if (page.nextToken != null) { + final PaginationCursor[] next = new PaginationCursor[1]; + PaginationCursor.decode(page.nextToken, CType.ROLES, PaginationParams.SORT_ASC).valid(c -> next[0] = c); + cursor = next[0]; + } else { + cursor = null; + } + } while (cursor != null); + + assertThat(seen.size(), is(all.size())); + assertThat(seen.keySet(), is(all.keySet())); + } + + @Test + public void descendingFirstPageOrderIsReversed() { + final var result = Paginator.paginate(entries(5), descParams(2), null, CType.ROLES); + + assertThat(result.entries.size(), is(2)); + assertThat(result.entries.keySet(), contains("entry_04", "entry_03")); + } + + @Test + public void descendingFullTraversalCoversAllEntries() throws Exception { + final var all = entries(5); + final var seen = new LinkedHashMap(); + PaginationCursor cursor = null; + + do { + final var params = new PaginationParams(2, PaginationParams.SORT_DESC, null); + final var page = Paginator.paginate(all, params, cursor, CType.ROLES); + seen.putAll(page.entries); + if (page.nextToken != null) { + final PaginationCursor[] next = new PaginationCursor[1]; + PaginationCursor.decode(page.nextToken, CType.ROLES, PaginationParams.SORT_DESC).valid(c -> next[0] = c); + cursor = next[0]; + } else { + cursor = null; + } + } while (cursor != null); + + assertThat(seen.size(), is(all.size())); + } + + @Test + public void continuationAfterDeletedCursorEntityUsesList() throws Exception { + final var all = entries(5); // entry_00 … entry_04 + final var page1 = Paginator.paginate(all, ascParams(2), null, CType.ROLES); + // cursor points to entry_01 (last of page 1) + final PaginationCursor[] cursor = new PaginationCursor[1]; + PaginationCursor.decode(page1.nextToken, CType.ROLES, PaginationParams.SORT_ASC).valid(c -> cursor[0] = c); + + // Simulate deletion of entry_01 (the cursor entity) before requesting page 2 + final var reduced = new LinkedHashMap<>(all); + reduced.remove("entry_01"); + + // Should lexicographically continue from "entry_01" → next is entry_02 + final var page2 = Paginator.paginate(reduced, ascParams(2), cursor[0], CType.ROLES); + + assertFalse(page2.entries.containsKey("entry_01")); + assertThat(page2.entries.keySet(), contains("entry_02", "entry_03")); + } + + @Test + public void additionAfterCursorDoesNotAppearOnCurrentPage() throws Exception { + final var all = entries(4); // entry_00 … entry_03 + final var page1 = Paginator.paginate(all, ascParams(2), null, CType.ROLES); + final PaginationCursor[] cursor = new PaginationCursor[1]; + PaginationCursor.decode(page1.nextToken, CType.ROLES, PaginationParams.SORT_ASC).valid(c -> cursor[0] = c); + + // Add entry_ZZ which sorts after everything + final var augmented = new LinkedHashMap<>(all); + augmented.put("entry_ZZ", "new"); + + final var page2 = Paginator.paginate(augmented, ascParams(2), cursor[0], CType.ROLES); + + // entry_02, entry_03 come next; entry_ZZ would appear on a subsequent page + assertFalse("entry added before cursor must not appear on this page", page2.entries.containsKey("entry_ZZ")); + assertThat(page2.entries.keySet(), contains("entry_02", "entry_03")); + } + + // ── null-safety ─────────────────────────────────────────────────────────── + @Test + public void nullAllEntriesThrows() { + assertThrows(NullPointerException.class, () -> Paginator.paginate(null, ascParams(5), null, CType.ROLES)); + } + + @Test + public void nullParamsThrows() { + assertThrows(NullPointerException.class, () -> Paginator.paginate(entries(3), null, null, CType.ROLES)); + } + + @Test + public void nullCTypeThrows() { + assertThrows(NullPointerException.class, () -> Paginator.paginate(entries(3), ascParams(5), null, null)); + } +} From 911c21038dd3aab7e138233cd5f52084c9971da9 Mon Sep 17 00:00:00 2001 From: Vishnutheep B Date: Sun, 9 Aug 2026 00:47:47 +0530 Subject: [PATCH 4/8] Add `withPaginatedGetRequest` to the overriden classes Signed-off-by: Vishnutheep B --- .../opensearch/security/dlic/rest/api/AbstractApiAction.java | 2 +- .../security/dlic/rest/api/InternalUsersApiAction.java | 3 ++- .../opensearch/security/dlic/rest/api/NodesDnApiAction.java | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) 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 f2702a7cb0..8af409d21d 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 @@ -139,7 +139,7 @@ private void buildDefaultRequestHandlers(final RequestHandler.RequestHandlersBui * or falls back to the legacy handler. * */ - private ValidationResult routeGetRequest(final RestRequest request) throws IOException { + protected ValidationResult routeGetRequest(final RestRequest request) throws IOException { if (PaginationRequestParser.isPaginationRequested(request)) { return processPaginatedGetRequest(request); } 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 04bfc94acf..8e2e359795 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 @@ -167,7 +167,8 @@ private void internalUsersApiRequestHandlers(RequestHandler.RequestHandlersBuild .map(securityConfiguration -> createOrUpdateAccount(request, securityConfiguration)) .map(this::validateAndUpdatePassword) .map(this::addEntityToConfig) - ); + ) + .withPaginatedGetRequest(this::routeGetRequest); } 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 fda92aecc1..150e88ea16 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 @@ -129,7 +129,7 @@ private void nodesDnApiRequestHandlers(RequestHandler.RequestHandlersBuilder req addStaticNodesDn(configuration); } return ValidationResult.success(securityConfiguration); - })).onChangeRequest(Method.PATCH, this::processPatchRequest); + })).onChangeRequest(Method.PATCH, this::processPatchRequest).withPaginatedGetRequest(this::routeGetRequest); } @SuppressWarnings("unchecked") From 3585239b7f14605e64f4e4d3ed6b4b620f25ec10 Mon Sep 17 00:00:00 2001 From: Vishnutheep B Date: Sun, 9 Aug 2026 00:48:59 +0530 Subject: [PATCH 5/8] Add integration tests for Pagination API Signed-off-by: Vishnutheep B --- .../api/PaginationRestApiIntegrationTest.java | 356 ++++++++++++++++++ 1 file changed, 356 insertions(+) create mode 100644 src/integrationTest/java/org/opensearch/security/api/PaginationRestApiIntegrationTest.java diff --git a/src/integrationTest/java/org/opensearch/security/api/PaginationRestApiIntegrationTest.java b/src/integrationTest/java/org/opensearch/security/api/PaginationRestApiIntegrationTest.java new file mode 100644 index 0000000000..a5cb32550e --- /dev/null +++ b/src/integrationTest/java/org/opensearch/security/api/PaginationRestApiIntegrationTest.java @@ -0,0 +1,356 @@ +/* + * 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.api; + +import java.util.ArrayList; + +import org.junit.ClassRule; +import org.junit.Test; + +import org.opensearch.security.dlic.rest.api.pagination.PaginationParams; +import org.opensearch.security.dlic.rest.api.pagination.PaginationRequestParser; +import org.opensearch.test.framework.cluster.LocalCluster; +import org.opensearch.test.framework.cluster.TestRestClient; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.greaterThan; +import static org.opensearch.test.framework.matcher.RestMatchers.isBadRequest; + +/** + * Integration tests for pagination on Security config collection GET APIs. + * + * Covered APIs: roles, internalusers, rolesmapping, actiongroups, tenants + */ +public class PaginationRestApiIntegrationTest extends AbstractApiIntegrationTest { + + private static final String SIZE = PaginationRequestParser.PARAM_SIZE; + private static final String SORT = PaginationRequestParser.PARAM_SORT; + private static final String TOKEN = PaginationRequestParser.PARAM_NEXT_TOKEN; + private static final String NEXT = PaginationRequestParser.RESPONSE_NEXT_TOKEN_KEY; + + @ClassRule + public static LocalCluster localCluster = clusterBuilder().build(); + + /** Appends ?key=val&… to an already-built API path. */ + private static String withParams(final String path, final String... kvPairs) { + final var sb = new StringBuilder(path).append('?'); + for (int i = 0; i < kvPairs.length; i += 2) { + if (i > 0) sb.append('&'); + sb.append(kvPairs[i]).append('=').append(kvPairs[i + 1]); + } + return sb.toString(); + } + + @Test + public void nonPaginatedGetReturnsLegacyFlatShape() throws Exception { + try (TestRestClient client = localCluster.getRestClient(ADMIN_USER)) { + final var resp = ok(() -> client.get(apiPath("roles"))); + final var body = resp.bodyAsJsonNode(); + // Legacy response has NO next_token field + assertThat("legacy response must not contain next_token", body.has(NEXT), is(false)); + assertThat(body.size(), greaterThan(0)); + } + } + + // ── single-page response when size ≥ total ── + @Test + public void largeSizeReturnsAllRolesOnOnePage() throws Exception { + try (TestRestClient client = localCluster.getRestClient(ADMIN_USER)) { + final var resp = ok(() -> client.get(withParams(apiPath("roles"), SIZE, "1000"))); + final var body = resp.bodyAsJsonNode(); + + assertThat(body.has(NEXT), is(true)); + assertThat("terminal page next_token must be null", body.get(NEXT).isNull(), is(true)); + assertThat(body.get("roles").size(), greaterThan(0)); + } + } + + // ── ascending multi-page traversal ── + + @Test + public void ascendingTraversalCoversAllRoles() throws Exception { + try (TestRestClient client = localCluster.getRestClient(ADMIN_USER)) { + // Fetch total count via non-paginated request + final int totalRoles = ok(() -> client.get(apiPath("roles"))).bodyAsJsonNode().size(); + + final var allSeen = new ArrayList(); + String nextToken = null; + + do { + final String path = nextToken == null + ? withParams(apiPath("roles"), SIZE, "2", SORT, "asc") + : withParams(apiPath("roles"), SIZE, "2", SORT, "asc", TOKEN, nextToken); + + final var resp = ok(() -> client.get(path)); + final var body = resp.bodyAsJsonNode(); + + assertThat("paginated response must have roles key", body.has("roles"), is(true)); + assertThat("paginated response must have next_token key", body.has(NEXT), is(true)); + + body.get("roles").propertyNames().forEach(allSeen::add); + nextToken = body.get(NEXT).isNull() ? null : body.get(NEXT).asText(); + } while (nextToken != null); + + assertThat("traversal must cover all roles", allSeen.size(), is(totalRoles)); + // Verify ascending order + for (int i = 1; i < allSeen.size(); i++) { + assertThat( + allSeen.get(i - 1) + " must sort before " + allSeen.get(i), + allSeen.get(i - 1).compareTo(allSeen.get(i)) < 0, + is(true) + ); + } + } + } + + @Test + public void descendingTraversalCoversAllRoles() throws Exception { + try (TestRestClient client = localCluster.getRestClient(ADMIN_USER)) { + final int totalRoles = ok(() -> client.get(apiPath("roles"))).bodyAsJsonNode().size(); + + final var allSeen = new ArrayList(); + String nextToken = null; + + do { + final String path = nextToken == null + ? withParams(apiPath("roles"), SIZE, "2", SORT, "desc") + : withParams(apiPath("roles"), SIZE, "2", SORT, "desc", TOKEN, nextToken); + + final var body = ok(() -> client.get(path)).bodyAsJsonNode(); + body.get("roles").propertyNames().forEach(allSeen::add); + nextToken = body.get(NEXT).isNull() ? null : body.get(NEXT).asText(); + } while (nextToken != null); + + assertThat("descending traversal must cover all roles", allSeen.size(), is(totalRoles)); + // Verify descending order + for (int i = 1; i < allSeen.size(); i++) { + assertThat( + allSeen.get(i - 1) + " must sort after " + allSeen.get(i), + allSeen.get(i - 1).compareTo(allSeen.get(i)) > 0, + is(true) + ); + } + } + } + + // ── terminal page has null next_token ── + + @Test + public void lastPageHasNullNextToken() throws Exception { + try (TestRestClient client = localCluster.getRestClient(ADMIN_USER)) { + // Request huge page — should return everything in one go + final var body = ok(() -> client.get(withParams(apiPath("roles"), SIZE, "1000"))).bodyAsJsonNode(); + assertThat(body.get(NEXT).isNull(), is(true)); + } + } + + // ── invalid params → 400 ── + + @Test + public void zeroSizeReturnsBadRequest() { + try (TestRestClient client = localCluster.getRestClient(ADMIN_USER)) { + assertThat(client.get(withParams(apiPath("roles"), SIZE, "0")), isBadRequest()); + } + } + + @Test + public void negativeSizeReturnsBadRequest() { + try (TestRestClient client = localCluster.getRestClient(ADMIN_USER)) { + assertThat(client.get(withParams(apiPath("roles"), SIZE, "-1")), isBadRequest()); + } + } + + @Test + public void sizeAboveMaxReturnsBadRequest() { + try (TestRestClient client = localCluster.getRestClient(ADMIN_USER)) { + assertThat(client.get(withParams(apiPath("roles"), SIZE, String.valueOf(PaginationParams.MAX_SIZE + 1))), isBadRequest()); + } + } + + @Test + public void nonIntegerSizeReturnsBadRequest() { + try (TestRestClient client = localCluster.getRestClient(ADMIN_USER)) { + assertThat(client.get(withParams(apiPath("roles"), SIZE, "abc")), isBadRequest()); + } + } + + @Test + public void invalidSortReturnsBadRequest() { + try (TestRestClient client = localCluster.getRestClient(ADMIN_USER)) { + assertThat(client.get(withParams(apiPath("roles"), SORT, "random")), isBadRequest()); + } + } + + @Test + public void malformedTokenReturnsBadRequest() { + try (TestRestClient client = localCluster.getRestClient(ADMIN_USER)) { + assertThat(client.get(withParams(apiPath("roles"), TOKEN, "not-a-valid-token!!")), isBadRequest()); + } + } + + @Test + public void crossEndpointTokenReturnsBadRequest() throws Exception { + try (TestRestClient client = localCluster.getRestClient(ADMIN_USER)) { + // Get a valid token from roles + final var resp = ok(() -> client.get(withParams(apiPath("roles"), SIZE, "1"))); + final var rolesToken = resp.bodyAsJsonNode().get(NEXT).asText(); + + // Present it to a different endpoint + assertThat(client.get(withParams(apiPath("internalusers"), TOKEN, rolesToken)), isBadRequest()); + } + } + + @Test + public void sortMismatchTokenReturnsBadRequest() throws Exception { + try (TestRestClient client = localCluster.getRestClient(ADMIN_USER)) { + // Get a token from an ascending request + final var resp = ok(() -> client.get(withParams(apiPath("roles"), SIZE, "1", SORT, "asc"))); + final var ascToken = resp.bodyAsJsonNode().get(NEXT).asText(); + + // Present it with a different sort direction + assertThat(client.get(withParams(apiPath("roles"), SORT, "desc", TOKEN, ascToken)), isBadRequest()); + } + } + + // ── single-entity GET + pagination params → 400 ── + + @Test + public void paginationOnSingleEntityGetReturnsBadRequest() { + try (TestRestClient client = localCluster.getRestClient(ADMIN_USER)) { + assertThat(client.get(withParams(apiPath("roles", "abcd"), SIZE, "5")), isBadRequest()); + } + } + + // ── hidden entity not visible in paginated result ── + + @Test + public void hiddenRolesNotLeakedInPaginatedResponse() throws Exception { + final String hiddenRoleName = "pagination_test_hidden_role"; + // Create a hidden role via TLS admin (super-admin can set hidden=true) + try (TestRestClient tlsClient = localCluster.getAdminCertRestClient()) { + tlsClient.putJson( + apiPath("roles", hiddenRoleName), + (builder, params) -> builder.startObject().field("hidden", true).startArray("cluster_permissions").endArray().endObject() + ); + } + + // Regular admin must not see the hidden role in paginated results + try (TestRestClient client = localCluster.getRestClient(ADMIN_USER)) { + final var allSeen = new ArrayList(); + String nextToken = null; + + do { + final String path = nextToken == null + ? withParams(apiPath("roles"), SIZE, "50", SORT, "asc") + : withParams(apiPath("roles"), SIZE, "50", SORT, "asc", TOKEN, nextToken); + final var body = ok(() -> client.get(path)).bodyAsJsonNode(); + body.get("roles").propertyNames().forEach(allSeen::add); + nextToken = body.get(NEXT).isNull() ? null : body.get(NEXT).asText(); + } while (nextToken != null); + + assertThat("hidden role must not be visible in paginated output", allSeen.contains(hiddenRoleName), is(false)); + } + } + + // ── pagination works across all six collection APIs ── + + @Test + public void paginationWorksForInternalUsers() throws Exception { + try (TestRestClient client = localCluster.getRestClient(ADMIN_USER)) { + final var resp = ok(() -> client.get(withParams(apiPath("internalusers"), SIZE, "1000"))); + final var body = resp.bodyAsJsonNode(); + assertThat(body.has(NEXT), is(true)); + assertThat(body.has("internalusers"), is(true)); + } + } + + @Test + public void paginationWorksForRolesMapping() throws Exception { + try (TestRestClient client = localCluster.getRestClient(ADMIN_USER)) { + final var resp = ok(() -> client.get(withParams(apiPath("rolesmapping"), SIZE, "1000"))); + final var body = resp.bodyAsJsonNode(); + assertThat(body.has(NEXT), is(true)); + assertThat(body.has("rolesmapping"), is(true)); + } + } + + @Test + public void paginationWorksForActionGroups() throws Exception { + try (TestRestClient client = localCluster.getRestClient(ADMIN_USER)) { + final var resp = ok(() -> client.get(withParams(apiPath("actiongroups"), SIZE, "1000"))); + final var body = resp.bodyAsJsonNode(); + assertThat(body.has(NEXT), is(true)); + assertThat(body.has("actiongroups"), is(true)); + } + } + + @Test + public void paginationWorksForTenants() throws Exception { + try (TestRestClient client = localCluster.getRestClient(ADMIN_USER)) { + final var resp = ok(() -> client.get(withParams(apiPath("tenants"), SIZE, "1000"))); + final var body = resp.bodyAsJsonNode(); + assertThat(body.has(NEXT), is(true)); + assertThat(body.has("tenants"), is(true)); + } + } + + // ── addition between pages does not break continuation ── + + @Test + public void additionBetweenPageRequestsDoesNotBreakContinuation() throws Exception { + final String newRoleName = "zzz_pagination_new_role"; + try (TestRestClient client = localCluster.getRestClient(ADMIN_USER)) { + // Get first page token + final var firstPage = ok(() -> client.get(withParams(apiPath("roles"), SIZE, "2", SORT, "asc"))); + final var firstToken = firstPage.bodyAsJsonNode().get(NEXT).asText(); + + // Add a new role that sorts AFTER existing ones (zzz prefix) + client.putJson( + apiPath("roles", newRoleName), + (builder, params) -> builder.startObject().startArray("cluster_permissions").endArray().endObject() + ); + + // Continue pagination from the saved token — must not throw or 400 + final var secondPage = ok(() -> client.get(withParams(apiPath("roles"), SIZE, "2", SORT, "asc", TOKEN, firstToken))); + assertThat(secondPage.bodyAsJsonNode().has("roles"), is(true)); + + // Cleanup + client.delete(apiPath("roles", newRoleName)); + } + } + + // ── deletion between pages (cursor entity removed) ── + + @Test + public void deletionOfCursorEntityDoesNotBreakContinuation() throws Exception { + final String tempRoleName = "aaa_pagination_temp_role"; + try (TestRestClient client = localCluster.getRestClient(ADMIN_USER)) { + // Create a role that sorts first (aaa prefix) + client.putJson( + apiPath("roles", tempRoleName), + (builder, params) -> builder.startObject().startArray("cluster_permissions").endArray().endObject() + ); + + // Get first page (size=1) — cursor should point to tempRoleName + final var firstPage = ok(() -> client.get(withParams(apiPath("roles"), SIZE, "1", SORT, "asc"))); + final var token = firstPage.bodyAsJsonNode().get(NEXT).asText(); + + // Delete the cursor entity + client.delete(apiPath("roles", tempRoleName)); + + // Continue from cursor — must succeed with lexical continuation + final var nextPage = ok(() -> client.get(withParams(apiPath("roles"), SIZE, "2", SORT, "asc", TOKEN, token))); + assertThat(nextPage.bodyAsJsonNode().has("roles"), is(true)); + } + } +} From 2a631dd903fa41b9d192aa59bd3bfa8577aa5bea Mon Sep 17 00:00:00 2001 From: Vishnutheep B Date: Thu, 27 Aug 2026 21:43:18 +0530 Subject: [PATCH 6/8] Make encoding consistent with opensearch's core repo Signed-off-by: Vishnutheep B --- .../dlic/rest/api/pagination/PaginationCursor.java | 6 +++--- .../dlic/rest/api/pagination/PaginationCursorTest.java | 8 +++----- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/src/main/java/org/opensearch/security/dlic/rest/api/pagination/PaginationCursor.java b/src/main/java/org/opensearch/security/dlic/rest/api/pagination/PaginationCursor.java index 50ddb536fa..750aac244c 100644 --- a/src/main/java/org/opensearch/security/dlic/rest/api/pagination/PaginationCursor.java +++ b/src/main/java/org/opensearch/security/dlic/rest/api/pagination/PaginationCursor.java @@ -40,7 +40,7 @@ public final class PaginationCursor { private static final String FIELD_SORT = "sort"; private static final String FIELD_LAST_KEY = "last_key"; - /** The opaque Base64url string received from the client as {@code next_token}. */ + /** The opaque Base64 string received from the client as {@code next_token}. */ public final String token; /** The last entity name included on the previous page — the resume position. */ @@ -67,7 +67,7 @@ public static PaginationCursor encode(final CType ctype, final String sort, f node.put(FIELD_LAST_KEY, lastKey); try { final String json = MAPPER.writeValueAsString(node); - final String encoded = Base64.getUrlEncoder().withoutPadding().encodeToString(json.getBytes(StandardCharsets.UTF_8)); + final String encoded = Base64.getEncoder().encodeToString(json.getBytes(StandardCharsets.UTF_8)); return new PaginationCursor(encoded, lastKey); } catch (Exception e) { throw new IllegalStateException("Failed to encode pagination cursor", e); @@ -85,7 +85,7 @@ public static PaginationCursor encode(final CType ctype, final String sort, f */ public static ValidationResult decode(final String encoded, final CType ctype, final String sort) { try { - final byte[] bytes = Base64.getUrlDecoder().decode(encoded); + final byte[] bytes = Base64.getDecoder().decode(encoded); final JsonNode node = MAPPER.readTree(new String(bytes, StandardCharsets.UTF_8)); if (!node.isObject()) { diff --git a/src/test/java/org/opensearch/security/dlic/rest/api/pagination/PaginationCursorTest.java b/src/test/java/org/opensearch/security/dlic/rest/api/pagination/PaginationCursorTest.java index c319451fc3..6ead50a877 100644 --- a/src/test/java/org/opensearch/security/dlic/rest/api/pagination/PaginationCursorTest.java +++ b/src/test/java/org/opensearch/security/dlic/rest/api/pagination/PaginationCursorTest.java @@ -77,7 +77,7 @@ public void decodeRejectsInvalidToken() { @Test public void decodeRejectsNonJsonPayload() { - final var encoded = Base64.getUrlEncoder().withoutPadding().encodeToString("hello world".getBytes(StandardCharsets.UTF_8)); + final var encoded = Base64.getEncoder().encodeToString("hello world".getBytes(StandardCharsets.UTF_8)); final var result = PaginationCursor.decode(encoded, CType.ROLES, PaginationParams.SORT_ASC); @@ -88,8 +88,7 @@ public void decodeRejectsNonJsonPayload() { @Test public void decodeRejectsMissingField() { // JSON object but last_key absent - final var encoded = Base64.getUrlEncoder() - .withoutPadding() + final var encoded = Base64.getEncoder() .encodeToString("{\"ctype\":\"roles\",\"sort\":\"asc\"}".getBytes(StandardCharsets.UTF_8)); final var result = PaginationCursor.decode(encoded, CType.ROLES, PaginationParams.SORT_ASC); @@ -101,8 +100,7 @@ public void decodeRejectsMissingField() { @Test public void decodeRejectsEmptyFieldValue() { // last_key is present but blank - final var encoded = Base64.getUrlEncoder() - .withoutPadding() + final var encoded = Base64.getEncoder() .encodeToString("{\"ctype\":\"roles\",\"sort\":\"asc\",\"last_key\":\"\"}".getBytes(StandardCharsets.UTF_8)); final var result = PaginationCursor.decode(encoded, CType.ROLES, PaginationParams.SORT_ASC); From 94a2be6f226d07a0a1d6e37e420cba0d41a5e0a3 Mon Sep 17 00:00:00 2001 From: Vishnutheep B Date: Thu, 27 Aug 2026 22:52:49 +0530 Subject: [PATCH 7/8] Fix spotlessCheck Signed-off-by: Vishnutheep B --- .../dlic/rest/api/pagination/PaginationCursorTest.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/test/java/org/opensearch/security/dlic/rest/api/pagination/PaginationCursorTest.java b/src/test/java/org/opensearch/security/dlic/rest/api/pagination/PaginationCursorTest.java index 6ead50a877..e9169040eb 100644 --- a/src/test/java/org/opensearch/security/dlic/rest/api/pagination/PaginationCursorTest.java +++ b/src/test/java/org/opensearch/security/dlic/rest/api/pagination/PaginationCursorTest.java @@ -88,8 +88,7 @@ public void decodeRejectsNonJsonPayload() { @Test public void decodeRejectsMissingField() { // JSON object but last_key absent - final var encoded = Base64.getEncoder() - .encodeToString("{\"ctype\":\"roles\",\"sort\":\"asc\"}".getBytes(StandardCharsets.UTF_8)); + final var encoded = Base64.getEncoder().encodeToString("{\"ctype\":\"roles\",\"sort\":\"asc\"}".getBytes(StandardCharsets.UTF_8)); final var result = PaginationCursor.decode(encoded, CType.ROLES, PaginationParams.SORT_ASC); From 3b6474feff0d287673a79f12549d1df4d989183e Mon Sep 17 00:00:00 2001 From: Vishnutheep B Date: Thu, 24 Sep 2026 23:09:20 +0530 Subject: [PATCH 8/8] Update RequestHandler.java Signed-off-by: Vishnutheep B --- .../org/opensearch/security/dlic/rest/api/RequestHandler.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 23fa67c514..5a62a79ca9 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 @@ -187,7 +187,7 @@ public RequestHandlersBuilder withPaginatedGetRequest( add(RestRequest.Method.GET, (channel, request, client) -> { final ValidationResult result = mapper.apply(request); if (result != null) { - result.valid(toXContent -> ok(channel, toXContent)) + result.valid(toXContent -> Responses.ok(channel, toXContent)) .error((status, toXContent) -> response(channel, status, toXContent)); } else { legacyHandler.handle(channel, request, client);