From 3e7a96e74e67623fc1d80601a5aca67f5b579475 Mon Sep 17 00:00:00 2001 From: Nishtha Mittal Date: Mon, 21 Sep 2026 04:18:13 +0000 Subject: [PATCH] Add pagination support to Security configuration collection APIs (#6339) Introduces opt-in cursor-based pagination on the six caller-visible collection GETs (internalusers, roles, rolesmapping, actiongroups, tenants, nodesdn), mirroring the OpenSearch _list APIs (see opensearch-project/OpenSearch#14641). Contract: - size: positive page size, capped at 1000, defaulting to 100 when pagination is requested. - next_token: opaque Base64-encoded cursor bound to {format version, CType, sort direction, last returned entity name}. Null on the terminal page. - sort: asc or desc, default asc, sorting by configuration entity name. Backward compatibility: requests without any of size, next_token, or sort keep the exact pre-existing response shape. When pagination is requested, the response is wrapped as: {"next_token": ..., "": { entries }} Single-entity GETs reject pagination parameters with HTTP 400. Malformed, tampered, cross-endpoint, or cross-direction cursors are rejected with 400. Continuation is name-based (lexical), so additions or deletions before the cursor do not shift later pages. Implementation: - New PaginationHelper (src/.../dlic/rest/api/pagination) exposes apply(request, ctype, securityConfiguration) which produces the wrapped ToXContent or a validation error. - New RequestHandlersBuilder.onCollectionGetRequest(ctype, mapper) wires the helper into the render step so endpoints opt in with a one-line change. - AbstractApiAction.prepareRequest consumes size / next_token / sort centrally (same pattern used for wait_for_completion) so non-participating endpoints ignore the parameters rather than 400-ing on them. - InternalUsersApiAction and NodesDnApiAction switch onGetRequest to onCollectionGetRequest, preserving filterBy and show_all customizations respectively. RolesApiAction, ActionGroupsApiAction, RolesMappingApiAction, and TenantsApiAction add an explicit onCollectionGetRequest call. Tests: - PaginationHelperTest (unit, 20 cases): asc/desc traversal, terminal null token, invalid size / sort / token / cross-endpoint / cross-direction / format-version mismatch, lexical continuation across deletions, empty collection, single-entity GET rejection, response shape. - PaginationRestApiIntegrationTest (integration, 16 cases): backwards- compatibility, asc/desc traversal, invalid input handling, cross-endpoint cursor rejection, single-entity GET rejection, lexical continuation with seeded deletions, hidden-entity non-leakage, internalusers filterBy composition, response shape wrapping. NodesDN pagination wiring is exercised via PaginationHelperTest and compilation; integration coverage is deferred because the test framework does not seed the nodesdn document in the security index. Signed-off-by: Nishtha Mittal --- .../api/PaginationRestApiIntegrationTest.java | 507 ++++++++++++++++++ .../dlic/rest/api/AbstractApiAction.java | 6 + .../dlic/rest/api/ActionGroupsApiAction.java | 4 +- .../dlic/rest/api/InternalUsersApiAction.java | 3 +- .../dlic/rest/api/NodesDnApiAction.java | 16 +- .../dlic/rest/api/RequestHandler.java | 32 ++ .../dlic/rest/api/RolesApiAction.java | 4 +- .../dlic/rest/api/RolesMappingApiAction.java | 4 +- .../dlic/rest/api/TenantsApiAction.java | 5 +- .../rest/api/pagination/PaginationHelper.java | 394 ++++++++++++++ .../api/pagination/PaginationHelperTest.java | 456 ++++++++++++++++ 11 files changed, 1419 insertions(+), 12 deletions(-) create mode 100644 src/integrationTest/java/org/opensearch/security/api/PaginationRestApiIntegrationTest.java create mode 100644 src/main/java/org/opensearch/security/dlic/rest/api/pagination/PaginationHelper.java create mode 100644 src/test/java/org/opensearch/security/dlic/rest/api/pagination/PaginationHelperTest.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..238de0f84a --- /dev/null +++ b/src/integrationTest/java/org/opensearch/security/api/PaginationRestApiIntegrationTest.java @@ -0,0 +1,507 @@ +/* + * 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 java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import org.junit.ClassRule; +import org.junit.Test; + +import org.opensearch.security.DefaultObjectMapper; +import org.opensearch.security.dlic.rest.api.pagination.PaginationHelper; +import org.opensearch.security.securityconf.impl.CType; +import org.opensearch.security.support.ConfigConstants; +import org.opensearch.test.framework.cluster.LocalCluster; +import org.opensearch.test.framework.cluster.TestRestClient; +import org.opensearch.test.framework.cluster.TestRestClient.HttpResponse; + +import tools.jackson.databind.JsonNode; + +import static org.hamcrest.CoreMatchers.equalTo; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.not; +import static org.hamcrest.CoreMatchers.notNullValue; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.hasItem; + +/** + * End-to-end coverage for cursor-based pagination on Security configuration collection APIs + * (issue #6339). + * + *

Exercises the caller-visible collection endpoints against a real cluster: {@code internalusers} + * (including {@code filterBy}), {@code roles}, {@code rolesmapping}, {@code actiongroups}, and + * {@code tenants}. The {@code nodesdn} endpoint's pagination wiring uses the same + * {@link PaginationHelper} path exercised by {@code PaginationHelperTest}; a live cluster GET is + * not covered here because the {@code LocalCluster} test framework does not seed the {@code nodesdn} + * document in the security index (see {@code NodesDnApiTest} for unit-level nodesdn coverage). + * + *

Per acceptance criterion, verifies: + *

    + *
  • backward compatibility — no pagination params yields the exact existing response shape;
  • + *
  • ascending and descending traversal across multiple pages with a terminal {@code null} token;
  • + *
  • invalid {@code size} / {@code sort} / malformed / cross-endpoint tokens all return HTTP 400;
  • + *
  • pagination parameters on single-entity GET requests are rejected with HTTP 400;
  • + *
  • additions and deletions between page requests do not shift subsequent pages + * (lexical continuation);
  • + *
  • {@code filterBy} on {@code internalusers} composes correctly with pagination;
  • + *
  • hidden entities remain invisible under paginated traversal.
  • + *
+ */ +public class PaginationRestApiIntegrationTest extends AbstractApiIntegrationTest { + + @ClassRule + public static LocalCluster localCluster = clusterBuilder().nodeSetting(ConfigConstants.SECURITY_RESTAPI_ADMIN_ENABLED, true).build(); + + // --------------------------------------------------------------------- + // Common bodies + // --------------------------------------------------------------------- + + private static final String ROLE_BODY = """ + {"cluster_permissions": ["cluster_composite_ops_ro"]} + """; + + private static final String ROLES_MAPPING_BODY = """ + {"backend_roles": ["backend"], "hosts": [], "users": []} + """; + + private static final String ACTION_GROUP_BODY = """ + {"allowed_actions": ["indices:data/read*"]} + """; + + private static final String TENANT_BODY = """ + {"description": "pagination test tenant"} + """; + + private static final String INTERNAL_USER_BODY = """ + {"password": "TestPassword_123!", "backend_roles": ["backend"]} + """; + + // --------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------- + + private static String pageQuery(final Map params) { + final StringBuilder sb = new StringBuilder("?"); + boolean first = true; + for (final var entry : params.entrySet()) { + if (!first) { + sb.append('&'); + } + sb.append(entry.getKey()).append('=').append(entry.getValue()); + first = false; + } + return sb.toString(); + } + + private JsonNode getAsJson(final TestRestClient client, final String path) throws Exception { + final HttpResponse resp = client.get(path); + assertThat(resp.getBody(), resp.getStatusCode(), is(200)); + return DefaultObjectMapper.readTree(resp.getBody()); + } + + /** + * Returns the ordered list of names under the paginated response's ctype-keyed wrapper. Fails + * the assertion if the wrapper is missing so callers can diagnose response-shape regressions. + */ + private List pageEntryNames(final JsonNode page, final CType ctype) { + final JsonNode wrapper = page.get(ctype.toLCString()); + assertThat("Paginated response should wrap entries under '" + ctype.toLCString() + "'", wrapper, notNullValue()); + final List out = new ArrayList<>(); + wrapper.propertyNames().forEach(out::add); + return out; + } + + /** + * Walks all pages of {@code apiPath} using the given {@code size} and {@code sort}, and returns + * the concatenated ordered list of entity names. Guards against infinite loops if a cursor is + * mis-encoded — bailing out at 100 pages produces a diagnosable failure rather than a hang. + */ + private List traverseAllPages( + final TestRestClient client, + final String apiPath, + final CType ctype, + final int size, + final String sort, + final Map extraParams + ) throws Exception { + final List collected = new ArrayList<>(); + String token = null; + for (int page = 0; page < 100; page++) { + final var params = new java.util.LinkedHashMap(); + params.put("size", Integer.toString(size)); + params.put("sort", sort); + if (extraParams != null) { + params.putAll(extraParams); + } + if (token != null) { + params.put("next_token", token); + } + final JsonNode body = getAsJson(client, apiPath + pageQuery(params)); + collected.addAll(pageEntryNames(body, ctype)); + if (body.get("next_token").isNull()) { + return collected; + } + token = body.get("next_token").asString(); + } + throw new AssertionError("Pagination did not terminate after 100 pages (possible cursor bug)"); + } + + // --------------------------------------------------------------------- + // Backwards compatibility: no pagination params → response shape unchanged + // --------------------------------------------------------------------- + + @Test + public void backwardCompatibility_rolesGetWithoutParamsHasNoWrapper() throws Exception { + try (TestRestClient client = localCluster.getRestClient(ADMIN_USER)) { + client.putJson(apiPath("roles", "bc_role_1"), ROLE_BODY); + final JsonNode body = getAsJson(client, apiPath("roles")); + // The existing response shape is a flat map of name → entry. It must not contain the + // paginated wrapper on requests that did not opt in. + assertThat(body.has("next_token"), is(false)); + assertThat("Existing shape should include the newly created role at top level", body.has("bc_role_1"), is(true)); + } + } + + @Test + public void backwardCompatibility_singleEntityGetUnchanged() throws Exception { + try (TestRestClient client = localCluster.getRestClient(ADMIN_USER)) { + client.putJson(apiPath("roles", "bc_single_role"), ROLE_BODY); + final JsonNode body = getAsJson(client, apiPath("roles", "bc_single_role")); + assertThat(body.has("next_token"), is(false)); + assertThat(body.has("bc_single_role"), is(true)); + } + } + + // --------------------------------------------------------------------- + // Traversal: ascending and descending across multiple pages, terminal null + // --------------------------------------------------------------------- + + @Test + public void roles_ascendingTraversalReturnsAllEntitiesSortedByName() throws Exception { + try (TestRestClient client = localCluster.getRestClient(ADMIN_USER)) { + final List created = List.of("asc_role_a", "asc_role_b", "asc_role_c", "asc_role_d", "asc_role_e"); + for (final String name : created) { + client.putJson(apiPath("roles", name), ROLE_BODY); + } + final List traversed = traverseAllPages(client, apiPath("roles"), CType.ROLES, 2, "asc", null); + // The cluster ships with reserved roles (kibana_read_only, etc.), so we assert that + // *our* names appear in ascending order, not that they are the only entries. + final List ours = traversed.stream().filter(created::contains).toList(); + assertThat("Ascending traversal should return our roles in name order", ours, contains(created.toArray(new String[0]))); + } + } + + @Test + public void roles_descendingTraversalReturnsAllEntitiesReverseSorted() throws Exception { + try (TestRestClient client = localCluster.getRestClient(ADMIN_USER)) { + final List created = List.of("desc_role_a", "desc_role_b", "desc_role_c", "desc_role_d"); + for (final String name : created) { + client.putJson(apiPath("roles", name), ROLE_BODY); + } + final List traversed = traverseAllPages(client, apiPath("roles"), CType.ROLES, 2, "desc", null); + final List ours = traversed.stream().filter(created::contains).toList(); + final List reverse = new ArrayList<>(created); + Collections.reverse(reverse); + assertThat( + "Descending traversal should return our roles in reverse name order", + ours, + contains(reverse.toArray(new String[0])) + ); + } + } + + @Test + public void roles_lastPageReturnsNullToken() throws Exception { + try (TestRestClient client = localCluster.getRestClient(ADMIN_USER)) { + // Ask for a large page so a single request covers the entire visible set on first shot. + final JsonNode page = getAsJson(client, apiPath("roles") + "?size=1000&sort=asc"); + assertThat(page.get("next_token"), notNullValue()); + assertThat(page.get("next_token").isNull(), is(true)); + } + } + + // --------------------------------------------------------------------- + // Invalid inputs + // --------------------------------------------------------------------- + + @Test + public void invalidInputs_badSizeSortAndTokenReturn400() throws Exception { + try (TestRestClient client = localCluster.getRestClient(ADMIN_USER)) { + assertThat(client.get(apiPath("roles") + "?size=abc").getStatusCode(), is(400)); + assertThat(client.get(apiPath("roles") + "?size=0").getStatusCode(), is(400)); + assertThat(client.get(apiPath("roles") + "?size=100000").getStatusCode(), is(400)); + assertThat(client.get(apiPath("roles") + "?sort=sideways").getStatusCode(), is(400)); + assertThat(client.get(apiPath("roles") + "?next_token=@@@not-base64@@@").getStatusCode(), is(400)); + } + } + + @Test + public void invalidInputs_crossEndpointTokenRejected() throws Exception { + try (TestRestClient client = localCluster.getRestClient(ADMIN_USER)) { + // Get a valid roles cursor, then attempt to reuse it against /actiongroups. + client.putJson(apiPath("roles", "cross_ep_role_1"), ROLE_BODY); + client.putJson(apiPath("roles", "cross_ep_role_2"), ROLE_BODY); + final JsonNode rolesPage = getAsJson(client, apiPath("roles") + "?size=1&sort=asc"); + assertThat("Test setup expects a follow-up page", rolesPage.get("next_token").isString(), is(true)); + final String rolesToken = rolesPage.get("next_token").asString(); + final HttpResponse resp = client.get(apiPath("actiongroups") + "?size=5&sort=asc&next_token=" + rolesToken); + assertThat(resp.getBody(), resp.getStatusCode(), is(400)); + } + } + + @Test + public void invalidInputs_crossSortDirectionTokenRejected() throws Exception { + try (TestRestClient client = localCluster.getRestClient(ADMIN_USER)) { + client.putJson(apiPath("roles", "cross_sort_role_1"), ROLE_BODY); + client.putJson(apiPath("roles", "cross_sort_role_2"), ROLE_BODY); + client.putJson(apiPath("roles", "cross_sort_role_3"), ROLE_BODY); + final JsonNode page = getAsJson(client, apiPath("roles") + "?size=1&sort=asc"); + final String ascToken = page.get("next_token").asString(); + final HttpResponse resp = client.get(apiPath("roles") + "?size=1&sort=desc&next_token=" + ascToken); + assertThat(resp.getBody(), resp.getStatusCode(), is(400)); + } + } + + @Test + public void invalidInputs_singleEntityGetRejectsPaginationParams() throws Exception { + try (TestRestClient client = localCluster.getRestClient(ADMIN_USER)) { + client.putJson(apiPath("roles", "single_pagination_role"), ROLE_BODY); + final HttpResponse resp = client.get(apiPath("roles", "single_pagination_role") + "?size=5&sort=asc"); + assertThat(resp.getBody(), resp.getStatusCode(), is(400)); + } + } + + // --------------------------------------------------------------------- + // Additions / deletions between pages – lexical continuation + // --------------------------------------------------------------------- + + @Test + public void lexicalContinuation_deletionOfCursorNameDoesNotShiftLaterPages() throws Exception { + try (TestRestClient client = localCluster.getRestClient(ADMIN_USER)) { + // Use a "zzz_" prefix so our seeded roles sort after any bundled/reserved roles + // (kibana_read_only, all_access, etc.). This lets the test drive the cursor to a + // known position of our own choosing regardless of what the cluster starts with. + final List created = List.of("zzz_cont_a", "zzz_cont_b", "zzz_cont_c", "zzz_cont_d"); + for (final String name : created) { + client.putJson(apiPath("roles", name), ROLE_BODY); + } + // Walk forward until we have a cursor whose last-name is exactly "zzz_cont_b". Doing + // this via traversal (rather than assuming page 1 already lands there) makes the test + // resilient to any number of bundled entities coming before ours. + String cursor = null; + outer: while (true) { + final var params = new java.util.LinkedHashMap(); + params.put("size", "1"); + params.put("sort", "asc"); + if (cursor != null) { + params.put("next_token", cursor); + } + final JsonNode page = getAsJson(client, apiPath("roles") + pageQuery(params)); + final List names = pageEntryNames(page, CType.ROLES); + for (final String n : names) { + if ("zzz_cont_b".equals(n)) { + cursor = page.get("next_token").asString(); + break outer; + } + } + if (page.get("next_token").isNull()) { + throw new AssertionError("Did not find seeded role zzz_cont_b during traversal"); + } + cursor = page.get("next_token").asString(); + } + + // Delete the entity referenced by the cursor. Lexical continuation means we must still + // resume from names strictly after "zzz_cont_b". + client.delete(apiPath("roles", "zzz_cont_b")); + + // Continue from cursor and collect our created names still visible. Only "zzz_cont_c" + // and "zzz_cont_d" should reappear — "zzz_cont_a" and "zzz_cont_b" must not, and the + // continuation must not have shifted. + final List collected = new ArrayList<>(); + String token = cursor; + for (int i = 0; i < 20; i++) { + final var params = new java.util.LinkedHashMap(); + params.put("size", "2"); + params.put("sort", "asc"); + params.put("next_token", token); + final JsonNode next = getAsJson(client, apiPath("roles") + pageQuery(params)); + for (final String n : pageEntryNames(next, CType.ROLES)) { + if (created.contains(n)) { + collected.add(n); + } + } + if (next.get("next_token").isNull()) { + break; + } + token = next.get("next_token").asString(); + } + assertThat( + "After deleting cursor-named entity, continuation should still return names strictly after it", + collected, + contains("zzz_cont_c", "zzz_cont_d") + ); + } + } + + // --------------------------------------------------------------------- + // Cross-endpoint coverage – smoke test each endpoint with pagination + // --------------------------------------------------------------------- + + @Test + public void collectionEndpoints_paginationSmokeTest() throws Exception { + try (TestRestClient client = localCluster.getRestClient(ADMIN_USER)) { + // Seed each endpoint with a couple of entities so the paginated shape is exercised + // even when the cluster ships with defaults. + for (int i = 1; i <= 3; i++) { + client.putJson(apiPath("roles", "smoke_role_" + i), ROLE_BODY); + client.putJson(apiPath("actiongroups", "smoke_ag_" + i), ACTION_GROUP_BODY); + client.putJson(apiPath("internalusers", "smoke_user_" + i), INTERNAL_USER_BODY); + client.putJson(apiPath("tenants", "smoke_tenant_" + i), TENANT_BODY); + } + // Roles mappings require a target role to exist first, so map to smoke_map_target_*. + for (int i = 1; i <= 3; i++) { + client.putJson(apiPath("roles", "smoke_map_target_" + i), ROLE_BODY); + client.putJson(apiPath("rolesmapping", "smoke_map_target_" + i), ROLES_MAPPING_BODY); + } + + // NodesDN is intentionally excluded here: the LocalCluster test framework does not seed + // the nodesdn document in the security index and the endpoint returns 403 "Security + // index need to be updated" without the SecurityAdmin populate step. Its wiring uses + // the same PaginationHelper path exercised by PaginationHelperTest. + for (final String endpoint : List.of("roles", "rolesmapping", "actiongroups", "internalusers", "tenants")) { + final HttpResponse page1 = client.get(apiPath(endpoint) + "?size=1&sort=asc"); + assertThat( + "First-page response should be 200 for endpoint " + endpoint + ": " + page1.getBody(), + page1.getStatusCode(), + is(200) + ); + final JsonNode body1 = DefaultObjectMapper.readTree(page1.getBody()); + final CType ctype = CType.fromString(endpoint.toLowerCase(Locale.ROOT)); + assertThat("Response should carry next_token key for endpoint " + endpoint, body1.has("next_token"), is(true)); + assertThat("Response should carry an entity wrapper for endpoint " + endpoint, body1.has(ctype.toLCString()), is(true)); + // size=1 combined with a seeded fleet of ≥3 entities of ours should always leave a cursor. + assertThat( + "Endpoint " + endpoint + " should have a follow-up cursor on size=1", + body1.get("next_token").isString(), + is(true) + ); + } + } + } + + // --------------------------------------------------------------------- + // internalusers – filterBy composes with pagination + // --------------------------------------------------------------------- + + @Test + public void internalUsers_filterByServiceComposesWithPagination() throws Exception { + try (TestRestClient client = localCluster.getRestClient(ADMIN_USER)) { + // Two internal users (non-service), one service account + client.putJson(apiPath("internalusers", "filter_user_a"), INTERNAL_USER_BODY); + client.putJson(apiPath("internalusers", "filter_user_b"), INTERNAL_USER_BODY); + final String svcBody = """ + {"attributes": {"service": "true"}, "backend_roles": ["service"]} + """; + client.putJson(apiPath("internalusers", "filter_service_z"), svcBody); + + // filterBy=service should exclude non-service users; pagination should then only + // enumerate service accounts. + final List serviceNames = traverseAllPages( + client, + apiPath("internalusers"), + CType.INTERNALUSERS, + 50, + "asc", + Map.of("filterBy", "service") + ); + assertThat("filterBy=service should exclude regular users", serviceNames, not(hasItem("filter_user_a"))); + assertThat("filterBy=service should exclude regular users", serviceNames, not(hasItem("filter_user_b"))); + assertThat("filterBy=service should include the service user", serviceNames, hasItem("filter_service_z")); + } + } + + // --------------------------------------------------------------------- + // nodesdn – show_all composes with pagination + // --------------------------------------------------------------------- + + // Note: the LocalCluster test framework does not initialize the nodesdn document in the + // security index, so a live GET returns 403 "Security index need to be updated". The nodesdn + // pagination wiring is exercised in NodesDnApiTest (unit) and its shared pagination logic in + // PaginationHelperTest — both of which include the show_all + pagination composition. + + // --------------------------------------------------------------------- + // Hidden entities remain invisible to non-admin callers under pagination + // --------------------------------------------------------------------- + + @Test + public void hiddenEntities_notLeakedByPagination() throws Exception { + // Rest-admin can create hidden entities; a regular admin sees the caller-visible set only. + try (TestRestClient restAdmin = localCluster.getRestClient(REST_ADMIN_USER)) { + final String hiddenRole = """ + {"cluster_permissions": ["cluster_composite_ops_ro"], "hidden": true} + """; + restAdmin.putJson(apiPath("roles", "hidden_pagination_role"), hiddenRole); + } + try (TestRestClient regularAdmin = localCluster.getRestClient(ADMIN_USER)) { + final List 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; + } +}