diff --git a/core/src/main/java/org/apache/iceberg/rest/ClientCapability.java b/core/src/main/java/org/apache/iceberg/rest/ClientCapability.java new file mode 100644 index 000000000000..1a616b56b5c6 --- /dev/null +++ b/core/src/main/java/org/apache/iceberg/rest/ClientCapability.java @@ -0,0 +1,92 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.rest; + +import java.util.Arrays; +import org.apache.iceberg.relocated.com.google.common.base.Joiner; + +/** + * Capabilities the REST client SDK declares it supports to the catalog server. + * + *

The set of supported capabilities is sent on every REST request via the {@code + * X-Iceberg-Client-Capabilities} header as a comma-separated list of {@link #headerValue() header + * values}. The header is purely informational: the server MAY use it to tailor responses, but MUST + * NOT require it and MUST NOT fail when it is absent. + * + *

Capabilities are independent of one another. A client that supports {@link + * #VENDED_CREDENTIALS} does not preclude support for {@link #REMOTE_SIGNING} or {@link + * #SCAN_PLANNING}; clients should advertise every capability they support. + */ +public enum ClientCapability { + /** + * Client supports receiving and using storage credentials vended by the catalog server. + * + *

When advertised, the server MAY return a {@code storage-credentials} array in {@code + * LoadTableResult} (and equivalent load responses) where each entry contains a storage location + * prefix and a config map of credentials (e.g. {@code s3.access-key-id}, {@code + * s3.session-token}, GCS or ADLS equivalents). The client passes these to its {@link + * org.apache.iceberg.io.FileIO} when the implementation also implements {@link + * org.apache.iceberg.io.SupportsStorageCredentials}. Vended credentials take precedence over + * inline credentials in the response {@code config} map. + */ + VENDED_CREDENTIALS("vended-credentials"), + + /** + * Client supports delegating S3 request signing to a remote signing service exposed by the + * catalog server. + * + *

When advertised, the client may issue per-request signing calls to {@code POST + * /v1/{prefix}/namespaces/{namespace}/tables/{table}/sign} with a {@link + * org.apache.iceberg.rest.requests.RemoteSignRequest} (region, method, URI, headers, optional + * body) and use the signed URI and headers returned in {@link + * org.apache.iceberg.rest.responses.RemoteSignResponse} to perform the actual S3 operation. This + * allows the catalog to centrally control AWS credentials without distributing them to clients. + */ + REMOTE_SIGNING("remote-signing"), + + /** + * Client supports server-side scan planning via the REST API. + * + *

When advertised, the client can drive the asynchronous planning protocol: submit a {@link + * org.apache.iceberg.rest.requests.PlanTableScanRequest} to {@code POST + * /v1/{prefix}/namespaces/{namespace}/tables/{table}/plan}, poll {@code GET .../plan/{plan-id}} + * while the plan status is {@code SUBMITTED}, and fetch paginated {@link + * org.apache.iceberg.rest.requests.FetchScanTasksRequest} results once the plan is {@code + * COMPLETED}. This shifts manifest reading and task generation from the client to the server. + */ + SCAN_PLANNING("scan-planning"); + + /** + * Comma-separated list of every capability's {@link #headerValue()}, suitable for use as the + * {@code X-Iceberg-Client-Capabilities} request header value. + */ + public static final String HEADER_VALUE = + Joiner.on(',').join(Arrays.stream(values()).map(ClientCapability::headerValue).iterator()); + + private final String headerValue; + + ClientCapability(String headerValue) { + this.headerValue = headerValue; + } + + /** Returns the header token used to advertise this capability. */ + public String headerValue() { + return headerValue; + } +} diff --git a/core/src/main/java/org/apache/iceberg/rest/HTTPClient.java b/core/src/main/java/org/apache/iceberg/rest/HTTPClient.java index 46d9177b9571..7f5b4e7a31cb 100644 --- a/core/src/main/java/org/apache/iceberg/rest/HTTPClient.java +++ b/core/src/main/java/org/apache/iceberg/rest/HTTPClient.java @@ -80,6 +80,9 @@ public class HTTPClient extends BaseHTTPClient { @VisibleForTesting static final String CLIENT_GIT_COMMIT_SHORT_HEADER = "X-Client-Git-Commit-Short"; + @VisibleForTesting + static final String CLIENT_CAPABILITIES_HEADER = "X-Iceberg-Client-Capabilities"; + private static final String REST_MAX_RETRIES = "rest.client.max-retries"; static final String REST_MAX_CONNECTIONS = "rest.client.max-connections"; static final int REST_MAX_CONNECTIONS_DEFAULT = 100; @@ -548,6 +551,7 @@ public Builder withAuthSession(AuthSession session) { public HTTPClient build() { withHeader(CLIENT_VERSION_HEADER, IcebergBuild.fullVersion()); withHeader(CLIENT_GIT_COMMIT_SHORT_HEADER, IcebergBuild.gitCommitShortId()); + withHeader(CLIENT_CAPABILITIES_HEADER, ClientCapability.HEADER_VALUE); String proxyHostname = PropertyUtil.propertyAsString(properties, HTTPClient.REST_PROXY_HOSTNAME, null); diff --git a/core/src/test/java/org/apache/iceberg/rest/TestClientCapability.java b/core/src/test/java/org/apache/iceberg/rest/TestClientCapability.java new file mode 100644 index 000000000000..29cd5b6cbe67 --- /dev/null +++ b/core/src/test/java/org/apache/iceberg/rest/TestClientCapability.java @@ -0,0 +1,43 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.rest; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Arrays; +import org.junit.jupiter.api.Test; + +public class TestClientCapability { + + @Test + public void testHeaderValuesAreCorrect() { + assertThat(ClientCapability.VENDED_CREDENTIALS.headerValue()).isEqualTo("vended-credentials"); + assertThat(ClientCapability.REMOTE_SIGNING.headerValue()).isEqualTo("remote-signing"); + assertThat(ClientCapability.SCAN_PLANNING.headerValue()).isEqualTo("scan-planning"); + } + + @Test + public void testHeaderValueIncludesAllEnumConstants() { + String[] expected = + Arrays.stream(ClientCapability.values()) + .map(ClientCapability::headerValue) + .toArray(String[]::new); + assertThat(ClientCapability.HEADER_VALUE.split(",")).containsExactlyInAnyOrder(expected); + } +} diff --git a/core/src/test/java/org/apache/iceberg/rest/TestHTTPClient.java b/core/src/test/java/org/apache/iceberg/rest/TestHTTPClient.java index 701ae699f136..6ec62af1486e 100644 --- a/core/src/test/java/org/apache/iceberg/rest/TestHTTPClient.java +++ b/core/src/test/java/org/apache/iceberg/rest/TestHTTPClient.java @@ -169,6 +169,27 @@ public void testHeadFailure() throws JsonProcessingException { testHttpMethodOnFailure(HttpMethod.HEAD); } + @Test + public void testCapabilityHeaderOverridesUserConfig() throws IOException { + String userBogusValue = "user-supplied-bogus"; + try (RESTClient clientWithUserCapabilities = + HTTPClient.builder(ImmutableMap.of()) + .uri(URI) + .withHeader(HTTPClient.CLIENT_CAPABILITIES_HEADER, userBogusValue) + .withAuthSession(AuthSession.EMPTY) + .build()) { + String path = "v1/test-capability-override"; + HttpRequest mockRequest = + request("/" + path) + .withMethod(HttpMethod.HEAD.name().toUpperCase(Locale.ROOT)) + .withHeader(HTTPClient.CLIENT_CAPABILITIES_HEADER, ClientCapability.HEADER_VALUE); + HttpResponse mockResponse = response().withStatusCode(200); + mockServer.when(mockRequest).respond(mockResponse); + clientWithUserCapabilities.head(path, ImmutableMap.of(), (onError) -> {}); + mockServer.verify(mockRequest, VerificationTimes.exactly(1)); + } + } + @Test public void testProxyServer() throws IOException { int proxyPort = 1070; @@ -672,6 +693,7 @@ private static String addRequestTestCaseAndGetPath( .withHeader("Authorization", "Bearer " + BEARER_AUTH_TOKEN) .withHeader(HTTPClient.CLIENT_VERSION_HEADER, icebergBuildFullVersion) .withHeader(HTTPClient.CLIENT_GIT_COMMIT_SHORT_HEADER, icebergBuildGitCommitShort) + .withHeader(HTTPClient.CLIENT_CAPABILITIES_HEADER, ClientCapability.HEADER_VALUE) .withHeader(USER_AGENT, TEST_USER_AGENT); if (method.usesRequestBody()) { diff --git a/open-api/rest-catalog-open-api.yaml b/open-api/rest-catalog-open-api.yaml index 06d13ec133d9..d72862935a2c 100644 --- a/open-api/rest-catalog-open-api.yaml +++ b/open-api/rest-catalog-open-api.yaml @@ -76,6 +76,7 @@ paths: schema: type: string description: Warehouse location or identifier to request from the service + - $ref: '#/components/parameters/client-capabilities' description: " All REST clients should first call this route to get catalog configuration @@ -280,6 +281,7 @@ paths: schema: type: string example: "accounting%1Ftax" + - $ref: '#/components/parameters/client-capabilities' responses: 200: $ref: '#/components/responses/ListNamespacesResponse' @@ -311,6 +313,7 @@ paths: summary: Create a namespace parameters: - $ref: '#/components/parameters/idempotency-key' + - $ref: '#/components/parameters/client-capabilities' description: Create a namespace, with an optional set of properties. The server might also add properties, such as `last_modified_time` etc. @@ -358,6 +361,8 @@ paths: - Catalog API summary: Load the metadata properties for a namespace operationId: loadNamespaceMetadata + parameters: + - $ref: '#/components/parameters/client-capabilities' description: Return all stored metadata properties for a given namespace responses: 200: @@ -389,6 +394,8 @@ paths: - Catalog API summary: Check if a namespace exists operationId: namespaceExists + parameters: + - $ref: '#/components/parameters/client-capabilities' description: Check if a namespace exists. The response does not contain a body. responses: @@ -423,6 +430,7 @@ paths: operationId: dropNamespace parameters: - $ref: '#/components/parameters/idempotency-key' + - $ref: '#/components/parameters/client-capabilities' responses: 204: description: Success, no content @@ -469,6 +477,7 @@ paths: operationId: updateProperties parameters: - $ref: '#/components/parameters/idempotency-key' + - $ref: '#/components/parameters/client-capabilities' description: Set and/or remove properties on a namespace. The request body specifies a list of properties to remove and a map @@ -536,6 +545,7 @@ paths: parameters: - $ref: '#/components/parameters/page-token' - $ref: '#/components/parameters/page-size' + - $ref: '#/components/parameters/client-capabilities' responses: 200: $ref: '#/components/responses/ListTablesResponse' @@ -582,6 +592,7 @@ paths: parameters: - $ref: '#/components/parameters/data-access' - $ref: '#/components/parameters/idempotency-key' + - $ref: '#/components/parameters/client-capabilities' requestBody: required: true content: @@ -674,6 +685,7 @@ paths: operationId: planTableScan parameters: - $ref: '#/components/parameters/idempotency-key' + - $ref: '#/components/parameters/client-capabilities' requestBody: content: application/json: @@ -724,6 +736,8 @@ paths: - Catalog API summary: Fetches the result of scan planning for a plan-id operationId: fetchPlanningResult + parameters: + - $ref: '#/components/parameters/client-capabilities' description: > Fetches the result of scan planning for a plan-id. @@ -784,6 +798,7 @@ paths: operationId: cancelPlanning parameters: - $ref: '#/components/parameters/idempotency-key' + - $ref: '#/components/parameters/client-capabilities' description: > Cancels scan planning for a plan-id. @@ -847,6 +862,7 @@ paths: operationId: fetchScanTasks parameters: - $ref: '#/components/parameters/idempotency-key' + - $ref: '#/components/parameters/client-capabilities' description: Fetches result tasks for a plan task. requestBody: content: @@ -898,6 +914,7 @@ paths: parameters: - $ref: '#/components/parameters/data-access' - $ref: '#/components/parameters/idempotency-key' + - $ref: '#/components/parameters/client-capabilities' description: Register a table using given metadata file location. @@ -993,6 +1010,7 @@ paths: type: string enum: [ all, refs ] - $ref: '#/components/parameters/referenced-by' + - $ref: '#/components/parameters/client-capabilities' responses: 200: $ref: '#/components/responses/LoadTableResponse' @@ -1030,6 +1048,7 @@ paths: operationId: updateTable parameters: - $ref: '#/components/parameters/idempotency-key' + - $ref: '#/components/parameters/client-capabilities' description: Commit updates to a table. @@ -1158,6 +1177,7 @@ paths: schema: type: boolean default: false + - $ref: '#/components/parameters/client-capabilities' responses: 204: description: Success, no content @@ -1189,6 +1209,8 @@ paths: - Catalog API summary: Check if a table exists operationId: tableExists + parameters: + - $ref: '#/components/parameters/client-capabilities' description: Check if a table exists within a given namespace. The response does not contain a body. responses: @@ -1236,6 +1258,7 @@ paths: type: string description: The plan ID that has been used for server-side scan planning - $ref: '#/components/parameters/referenced-by' + - $ref: '#/components/parameters/client-capabilities' description: Load vended credentials for a table from the catalog. responses: 200: @@ -1274,6 +1297,8 @@ paths: - Catalog API summary: Remotely signs requests to object storage operationId: signRequest + parameters: + - $ref: '#/components/parameters/client-capabilities' requestBody: description: The request to be signed content: @@ -1307,6 +1332,7 @@ paths: summary: Rename a table from its current name to a new name parameters: - $ref: '#/components/parameters/idempotency-key' + - $ref: '#/components/parameters/client-capabilities' description: Rename a table from one identifier to another. It's valid to move a table across namespaces, but the server implementation is not required to support it. @@ -1372,6 +1398,8 @@ paths: - Catalog API summary: Send a metrics report to this endpoint to be processed by the backend operationId: reportMetrics + parameters: + - $ref: '#/components/parameters/client-capabilities' requestBody: description: The request containing the metrics report to be sent content: @@ -1416,6 +1444,7 @@ paths: operationId: commitTransaction parameters: - $ref: '#/components/parameters/idempotency-key' + - $ref: '#/components/parameters/client-capabilities' requestBody: description: Commit updates to multiple tables in an atomic operation @@ -1536,6 +1565,7 @@ paths: parameters: - $ref: '#/components/parameters/page-token' - $ref: '#/components/parameters/page-size' + - $ref: '#/components/parameters/client-capabilities' responses: 200: $ref: '#/components/responses/ListTablesResponse' @@ -1568,6 +1598,8 @@ paths: description: Create a view in the given namespace. operationId: createView + parameters: + - $ref: '#/components/parameters/client-capabilities' requestBody: required: true content: @@ -1636,6 +1668,7 @@ paths: key. For example, "urn:ietf:params:oauth:token-type:jwt=". parameters: - $ref: '#/components/parameters/referenced-by' + - $ref: '#/components/parameters/client-capabilities' responses: 200: $ref: '#/components/responses/LoadViewResponse' @@ -1669,6 +1702,7 @@ paths: operationId: replaceView parameters: - $ref: '#/components/parameters/idempotency-key' + - $ref: '#/components/parameters/client-capabilities' description: Commit updates to a view. requestBody: @@ -1772,6 +1806,7 @@ paths: description: Remove a view from the catalog parameters: - $ref: '#/components/parameters/idempotency-key' + - $ref: '#/components/parameters/client-capabilities' responses: 204: description: Success, no content @@ -1803,6 +1838,8 @@ paths: - Catalog API summary: Check if a view exists operationId: viewExists + parameters: + - $ref: '#/components/parameters/client-capabilities' description: Check if a view exists within a given namespace. This request does not return a response body. responses: @@ -1835,6 +1872,7 @@ paths: operationId: renameView parameters: - $ref: '#/components/parameters/idempotency-key' + - $ref: '#/components/parameters/client-capabilities' requestBody: description: Current view identifier to rename and new view identifier to rename to content: @@ -1896,6 +1934,7 @@ paths: summary: Register a view in the catalog parameters: - $ref: '#/components/parameters/idempotency-key' + - $ref: '#/components/parameters/client-capabilities' description: Register a view in the given namespace using given metadata file location. @@ -2019,6 +2058,50 @@ components: explode: false example: "vended-credentials,remote-signing" + client-capabilities: + name: X-Iceberg-Client-Capabilities + in: header + description: > + This header is a forward-compatibility hint, not a security mechanism. + Clients can trivially spoof its value; servers MUST NOT base trust or + authorization decisions on it. + + + Optional signal from the client declaring the set of capabilities that + the client SDK supports, as a comma-separated list. This header is + sent on every request. The server may use this information to tailor + its responses. + + + Defined capability values: + + - `vended-credentials`: The client supports receiving and using + storage credentials vended by the catalog server. + + - `remote-signing`: The client supports delegating request signing + to a remote signing service provided by the catalog server. + + - `scan-planning`: The client supports server-side scan planning. + + + Clients should include all capabilities they support. Servers must + treat this header as optional and must not fail if it is absent. + + + Client SDKs that support this header should set it statically and + include it on every request. Clients that do not yet support this + header statically can configure it via client side headers. + required: false + schema: + type: string + enum: + - vended-credentials + - remote-signing + - scan-planning + style: simple + explode: false + example: "vended-credentials,remote-signing,scan-planning" + page-token: name: pageToken in: query