diff --git a/.github/workflows/aws_test.yml b/.github/workflows/aws_test.yml new file mode 100644 index 000000000..a8d455006 --- /dev/null +++ b/.github/workflows/aws_test.yml @@ -0,0 +1,174 @@ +# 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. + +name: AWS Tests + +on: + push: + branches: + - '**' + - '!dependabot/**' + tags: + - '**' + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + +concurrency: + group: ${{ github.repository }}-${{ github.head_ref || github.sha }}-${{ github.workflow }} + cancel-in-progress: true + +permissions: + contents: read + +env: + ICEBERG_HOME: /tmp/iceberg + +jobs: + aws: + if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }} + name: AWS (${{ matrix.title }}) + runs-on: ${{ matrix.runs-on }} + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + include: + - title: Ubuntu 24.04, S3 + SigV4, bundled AWS SDK + runs-on: ubuntu-24.04 + CC: gcc-14 + CXX: g++-14 + s3: "ON" + sigv4: "ON" + bundle_awssdk: "ON" + - title: Ubuntu 24.04, S3 + SigV4, system AWS SDK + runs-on: ubuntu-24.04 + CC: gcc-14 + CXX: g++-14 + s3: "ON" + sigv4: "ON" + bundle_awssdk: "OFF" + aws-sdk-features: core,config,s3,identity-management,sts,transfer + - title: macOS 26 ARM64, S3, bundled AWS SDK + runs-on: macos-26 + s3: "ON" + sigv4: "OFF" + bundle_awssdk: "ON" + env: + ICEBERG_TEST_S3_URI: s3://iceberg-test + AWS_ACCESS_KEY_ID: minio + AWS_SECRET_ACCESS_KEY: minio123 + AWS_DEFAULT_REGION: us-east-1 + AWS_ENDPOINT_URL: http://127.0.0.1:9000 + AWS_EC2_METADATA_DISABLED: "TRUE" + steps: + - name: Checkout iceberg-cpp + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - name: Install dependencies on Ubuntu + if: ${{ startsWith(matrix.runs-on, 'ubuntu') }} + shell: bash + run: sudo apt-get update && sudo apt-get install -y libcurl4-openssl-dev + - name: Cache vcpkg packages + if: ${{ startsWith(matrix.runs-on, 'ubuntu') && matrix.bundle_awssdk == 'OFF' }} + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + id: vcpkg-cache + with: + path: /usr/local/share/vcpkg/installed + key: vcpkg-x64-linux-aws-sdk-cpp-s3-${{ matrix.s3 }}-sigv4-${{ matrix.sigv4 }}-${{ hashFiles('.github/workflows/aws_test.yml') }} + - name: Install AWS SDK via vcpkg + if: ${{ startsWith(matrix.runs-on, 'ubuntu') && matrix.bundle_awssdk == 'OFF' && steps.vcpkg-cache.outputs.cache-hit != 'true' }} + shell: bash + # Retry to ride out transient GitHub/mirror download failures (504s). + run: | + for attempt in 1 2 3; do + if vcpkg install "aws-sdk-cpp[${{ matrix.aws-sdk-features }}]:x64-linux"; then + exit 0 + fi + echo "::warning::vcpkg install failed (attempt ${attempt}/3), retrying in 30s" + sleep 30 + done + echo "::error::vcpkg install failed after 3 attempts" + exit 1 + - name: Set Ubuntu Compilers + if: ${{ startsWith(matrix.runs-on, 'ubuntu') }} + run: | + echo "CC=${{ matrix.CC }}" >> $GITHUB_ENV + echo "CXX=${{ matrix.CXX }}" >> $GITHUB_ENV + - name: Start MinIO + if: ${{ matrix.s3 == 'ON' }} + shell: bash + run: bash ci/scripts/start_minio.sh + - name: Build and test Iceberg + shell: bash + env: + CMAKE_TOOLCHAIN_FILE: ${{ startsWith(matrix.runs-on, 'ubuntu') && matrix.bundle_awssdk == 'OFF' && '/usr/local/share/vcpkg/scripts/buildsystems/vcpkg.cmake' || '' }} + run: ci/scripts/build_iceberg.sh "$(pwd)" OFF OFF ${{ matrix.s3 }} ${{ matrix.sigv4 }} ${{ matrix.bundle_awssdk }} + + # Exercise the Meson build with SigV4 enabled (resolves aws-cpp-sdk-core via + # its CMake config, not pkg-config whose Cflags force -std=c++11). + meson-sigv4: + if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }} + name: Meson SigV4 (AMD64 Ubuntu 24.04) + runs-on: ubuntu-24.04 + timeout-minutes: 45 + steps: + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: '3.x' + - name: Checkout iceberg-cpp + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - name: Install build dependencies + shell: bash + run: | + sudo apt-get update && sudo apt-get install -y libcurl4-openssl-dev + python3 -m pip install --upgrade pip + python3 -m pip install -r requirements.txt + - name: Cache vcpkg packages + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + id: vcpkg-cache + with: + path: /usr/local/share/vcpkg/installed + key: vcpkg-x64-linux-aws-sdk-cpp-core-${{ hashFiles('.github/workflows/aws_test.yml') }} + - name: Install AWS SDK via vcpkg + if: ${{ steps.vcpkg-cache.outputs.cache-hit != 'true' }} + shell: bash + # Retry to ride out transient GitHub/mirror download failures (504s). + run: | + for attempt in 1 2 3; do + if vcpkg install aws-sdk-cpp[core]:x64-linux; then + exit 0 + fi + echo "::warning::vcpkg install failed (attempt ${attempt}/3), retrying in 30s" + sleep 30 + done + echo "::error::vcpkg install failed after 3 attempts" + exit 1 + - name: Set Ubuntu Compilers + run: | + echo "CC=gcc-14" >> $GITHUB_ENV + echo "CXX=g++-14" >> $GITHUB_ENV + - name: Build and test Iceberg + shell: bash + env: + CMAKE_PREFIX_PATH: /usr/local/share/vcpkg/installed/x64-linux + run: | + meson setup builddir -Dsigv4=enabled + meson compile -C builddir + meson test -C builddir --timeout-multiplier 0 --print-errorlogs diff --git a/.github/workflows/s3_test.yml b/.github/workflows/s3_test.yml deleted file mode 100644 index 0cf8e8b1e..000000000 --- a/.github/workflows/s3_test.yml +++ /dev/null @@ -1,83 +0,0 @@ -# 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. - -# S3-backed tests against MinIO (Linux and macOS only). -name: S3 Tests - -on: - push: - branches: - - '**' - - '!dependabot/**' - tags: - - '**' - pull_request: - types: [opened, synchronize, reopened, ready_for_review] - -concurrency: - group: ${{ github.repository }}-${{ github.head_ref || github.sha }}-${{ github.workflow }} - cancel-in-progress: true - -permissions: - contents: read - -env: - ICEBERG_HOME: /tmp/iceberg - -jobs: - s3-minio: - if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }} - name: S3 (${{ matrix.title }}) - runs-on: ${{ matrix.runs-on }} - timeout-minutes: 35 - strategy: - fail-fast: false - matrix: - include: - - title: AMD64 Ubuntu 24.04 - runs-on: ubuntu-24.04 - CC: gcc-14 - CXX: g++-14 - - title: AArch64 macOS 26 - runs-on: macos-26 - env: - ICEBERG_TEST_S3_URI: s3://iceberg-test - AWS_ACCESS_KEY_ID: minio - AWS_SECRET_ACCESS_KEY: minio123 - AWS_DEFAULT_REGION: us-east-1 - AWS_ENDPOINT_URL: http://127.0.0.1:9000 - AWS_EC2_METADATA_DISABLED: "TRUE" - steps: - - name: Checkout iceberg-cpp - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - - name: Install dependencies on Ubuntu - if: ${{ startsWith(matrix.runs-on, 'ubuntu') }} - shell: bash - run: sudo apt-get update && sudo apt-get install -y libcurl4-openssl-dev - - name: Set Ubuntu Compilers - if: ${{ startsWith(matrix.runs-on, 'ubuntu') }} - run: | - echo "CC=${{ matrix.CC }}" >> $GITHUB_ENV - echo "CXX=${{ matrix.CXX }}" >> $GITHUB_ENV - - name: Start MinIO - shell: bash - run: bash ci/scripts/start_minio.sh - - name: Build and test Iceberg with S3 - shell: bash - run: ci/scripts/build_iceberg.sh "$(pwd)" OFF OFF ON diff --git a/CMakeLists.txt b/CMakeLists.txt index b03e586b0..69f90b54a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -53,6 +53,8 @@ option(ICEBERG_SQL_SQLITE "Build the SQLite connector for the SQL catalog" OFF) option(ICEBERG_SQL_POSTGRESQL "Build the PostgreSQL connector for the SQL catalog" OFF) option(ICEBERG_SQL_MYSQL "Build the MySQL connector for the SQL catalog" OFF) option(ICEBERG_S3 "Build with S3 support" OFF) +option(ICEBERG_SIGV4 "Build with SigV4 support" OFF) +option(ICEBERG_BUNDLE_AWSSDK "Bundle AWS SDK for S3/SigV4 support" ON) option(ICEBERG_ENABLE_ASAN "Enable Address Sanitizer" OFF) option(ICEBERG_ENABLE_UBSAN "Enable Undefined Behavior Sanitizer" OFF) @@ -76,12 +78,6 @@ if(ICEBERG_BUILD_REST_INTEGRATION_TESTS AND WIN32) message(WARNING "Cannot build rest integration test on Windows, turning it off.") endif() -# ICEBERG_S3 requires ICEBERG_BUILD_BUNDLE -if(NOT ICEBERG_BUILD_BUNDLE AND ICEBERG_S3) - set(ICEBERG_S3 OFF) - message(STATUS "ICEBERG_S3 is disabled because ICEBERG_BUILD_BUNDLE is OFF") -endif() - include(CMakeParseArguments) include(IcebergBuildUtils) include(IcebergSanitizer) diff --git a/ci/scripts/build_iceberg.sh b/ci/scripts/build_iceberg.sh index 406ef56a7..6af0802f6 100755 --- a/ci/scripts/build_iceberg.sh +++ b/ci/scripts/build_iceberg.sh @@ -17,7 +17,7 @@ # specific language governing permissions and limitations # under the License. # -# Usage: build_iceberg.sh [rest_integration_tests=OFF] [sccache=OFF] [s3=OFF] +# Usage: build_iceberg.sh [rest_integration_tests=OFF] [sccache=OFF] [s3=OFF] [sigv4=OFF] [bundle_awssdk=ON] set -eux @@ -26,6 +26,8 @@ build_dir=${1}/build build_rest_integration_test=${2:-OFF} build_enable_sccache=${3:-OFF} build_enable_s3=${4:-OFF} +build_enable_sigv4=${5:-OFF} +build_bundle_awssdk=${6:-ON} run_tests=${ICEBERG_RUN_TESTS:-ON} mkdir ${build_dir} @@ -49,10 +51,26 @@ else CMAKE_ARGS+=("-DICEBERG_S3=OFF") fi +if [[ "${build_enable_sigv4}" == "ON" ]]; then + CMAKE_ARGS+=("-DICEBERG_SIGV4=ON") +else + CMAKE_ARGS+=("-DICEBERG_SIGV4=OFF") +fi + +if [[ "${build_bundle_awssdk}" == "ON" ]]; then + CMAKE_ARGS+=("-DICEBERG_BUNDLE_AWSSDK=ON") +else + CMAKE_ARGS+=("-DICEBERG_BUNDLE_AWSSDK=OFF") +fi + if is_windows; then CMAKE_ARGS+=("-DCMAKE_TOOLCHAIN_FILE=C:/vcpkg/scripts/buildsystems/vcpkg.cmake") CMAKE_ARGS+=("-DCMAKE_BUILD_TYPE=Release") else + # Pass an externally provided toolchain (e.g. vcpkg for the SigV4 job) + if [[ -n "${CMAKE_TOOLCHAIN_FILE:-}" ]]; then + CMAKE_ARGS+=("-DCMAKE_TOOLCHAIN_FILE=${CMAKE_TOOLCHAIN_FILE}") + fi CMAKE_ARGS+=("-DCMAKE_BUILD_TYPE=Debug") fi diff --git a/cmake_modules/IcebergThirdpartyToolchain.cmake b/cmake_modules/IcebergThirdpartyToolchain.cmake index 152af0cb9..1430bb9cd 100644 --- a/cmake_modules/IcebergThirdpartyToolchain.cmake +++ b/cmake_modules/IcebergThirdpartyToolchain.cmake @@ -19,6 +19,48 @@ # third party libraries. set(ICEBERG_SYSTEM_DEPENDENCIES) set(ICEBERG_ARROW_INSTALL_INTERFACE_LIBS) +set(ICEBERG_AWSSDK_BUNDLED FALSE) +if(ICEBERG_S3 AND ICEBERG_BUNDLE_AWSSDK) + if(NOT ICEBERG_BUILD_BUNDLE) + message(FATAL_ERROR "ICEBERG_BUNDLE_AWSSDK requires ICEBERG_BUILD_BUNDLE to be ON") + endif() + set(ICEBERG_AWSSDK_BUNDLED TRUE) +endif() + +set(ICEBERG_AWSSDK_COMPONENTS) +if(NOT ICEBERG_AWSSDK_BUNDLED) + if(ICEBERG_S3) + list(APPEND + ICEBERG_AWSSDK_COMPONENTS + core + config + s3 + transfer + identity-management + sts) + elseif(ICEBERG_SIGV4) + list(APPEND ICEBERG_AWSSDK_COMPONENTS core) + endif() +endif() + +# ---------------------------------------------------------------------- +# AWS SDK for C++ + +function(resolve_aws_sdk_dependency) + if(NOT ICEBERG_AWSSDK_COMPONENTS) + return() + endif() + find_package(AWSSDK REQUIRED COMPONENTS ${ICEBERG_AWSSDK_COMPONENTS}) + list(APPEND ICEBERG_SYSTEM_DEPENDENCIES AWSSDK) + set(ICEBERG_SYSTEM_DEPENDENCIES + ${ICEBERG_SYSTEM_DEPENDENCIES} + PARENT_SCOPE) + # Forwarded to find_dependency(AWSSDK ...) in iceberg-config.cmake.in so + # downstream installed builds load the same AWS SDK targets. + set(ICEBERG_FIND_EXTRA_ARGS_AWSSDK + "COMPONENTS;${ICEBERG_AWSSDK_COMPONENTS}" + PARENT_SCOPE) +endfunction() # ---------------------------------------------------------------------- # Versions and URLs for toolchain builds @@ -111,6 +153,9 @@ function(resolve_arrow_dependency) set(ARROW_POSITION_INDEPENDENT_CODE ON) set(ARROW_DEPENDENCY_SOURCE "BUNDLED") set(ARROW_WITH_ZLIB ON) + if(ICEBERG_S3 AND NOT ICEBERG_AWSSDK_BUNDLED) + set(AWSSDK_SOURCE "SYSTEM") + endif() set(ZLIB_SOURCE "SYSTEM") set(ARROW_VERBOSE_THIRDPARTY_BUILD OFF) set(CMAKE_CXX_STANDARD 20) @@ -620,6 +665,13 @@ resolve_nanoarrow_dependency() resolve_croaring_dependency() resolve_nlohmann_json_dependency() +if(ICEBERG_S3 OR ICEBERG_SIGV4) + if(ICEBERG_SIGV4 AND NOT ICEBERG_BUILD_REST) + message(FATAL_ERROR "ICEBERG_SIGV4 requires ICEBERG_BUILD_REST to be ON") + endif() + resolve_aws_sdk_dependency() +endif() + if(ICEBERG_BUILD_BUNDLE) resolve_arrow_dependency() resolve_avro_dependency() diff --git a/meson.options b/meson.options index 9152af34d..c53574889 100644 --- a/meson.options +++ b/meson.options @@ -44,4 +44,11 @@ option( value: 'disabled', ) +option( + 'sigv4', + type: 'feature', + description: 'Build AWS SigV4 authentication support for rest catalog', + value: 'disabled', +) + option('tests', type: 'feature', description: 'Build tests', value: 'enabled') diff --git a/src/iceberg/catalog/rest/CMakeLists.txt b/src/iceberg/catalog/rest/CMakeLists.txt index 8fb2e93c0..b6438486a 100644 --- a/src/iceberg/catalog/rest/CMakeLists.txt +++ b/src/iceberg/catalog/rest/CMakeLists.txt @@ -23,6 +23,7 @@ set(ICEBERG_REST_SOURCES auth/auth_properties.cc auth/auth_session.cc auth/oauth2_util.cc + auth/sigv4_manager.cc auth/token_refresh_scheduler.cc catalog_properties.cc endpoint.cc @@ -53,6 +54,15 @@ list(APPEND "$,iceberg::iceberg_shared,iceberg::iceberg_static>" "$,iceberg::cpr,cpr::cpr>") +if(ICEBERG_SIGV4) + list(APPEND ICEBERG_REST_STATIC_BUILD_INTERFACE_LIBS aws-cpp-sdk-core) + list(APPEND ICEBERG_REST_SHARED_BUILD_INTERFACE_LIBS aws-cpp-sdk-core) + if(NOT ICEBERG_AWSSDK_BUNDLED) + list(APPEND ICEBERG_REST_STATIC_INSTALL_INTERFACE_LIBS aws-cpp-sdk-core) + list(APPEND ICEBERG_REST_SHARED_INSTALL_INTERFACE_LIBS aws-cpp-sdk-core) + endif() +endif() + add_iceberg_lib(iceberg_rest SOURCES ${ICEBERG_REST_SOURCES} @@ -65,4 +75,16 @@ add_iceberg_lib(iceberg_rest SHARED_INSTALL_INTERFACE_LIBS ${ICEBERG_REST_SHARED_INSTALL_INTERFACE_LIBS}) +foreach(LIB iceberg_rest_static iceberg_rest_shared) + if(TARGET ${LIB}) + if(ICEBERG_SIGV4) + target_compile_definitions(${LIB} + PUBLIC "$") + else() + target_compile_definitions(${LIB} + PUBLIC "$") + endif() + endif() +endforeach() + iceberg_install_all_headers(iceberg/catalog/rest) diff --git a/src/iceberg/catalog/rest/auth/auth_manager_internal.h b/src/iceberg/catalog/rest/auth/auth_manager_internal.h index 051d05505..36671a39f 100644 --- a/src/iceberg/catalog/rest/auth/auth_manager_internal.h +++ b/src/iceberg/catalog/rest/auth/auth_manager_internal.h @@ -47,4 +47,10 @@ Result> MakeOAuth2Manager( std::string_view name, const std::unordered_map& properties); +/// \brief Create a SigV4 authentication manager with a delegate. Returns +/// NotSupported when the library was built without ICEBERG_SIGV4. +Result> MakeSigV4AuthManager( + std::string_view name, + const std::unordered_map& properties); + } // namespace iceberg::rest::auth diff --git a/src/iceberg/catalog/rest/auth/auth_managers.cc b/src/iceberg/catalog/rest/auth/auth_managers.cc index f55885d75..6ee2637b3 100644 --- a/src/iceberg/catalog/rest/auth/auth_managers.cc +++ b/src/iceberg/catalog/rest/auth/auth_managers.cc @@ -46,6 +46,12 @@ const std::unordered_set& KnownAuthTypes() // Infer the authentication type from properties. std::string InferAuthType( const std::unordered_map& properties) { + // Deprecated alias: rest.sigv4-enabled=true forces SigV4. + if (auto it = properties.find(AuthProperties::kSigV4Enabled); + it != properties.end() && StringUtils::EqualsIgnoreCase(it->second, "true")) { + return AuthProperties::kAuthTypeSigV4; + } + auto it = properties.find(AuthProperties::kAuthType); if (it != properties.end() && !it->second.empty()) { return StringUtils::ToLower(it->second); @@ -61,17 +67,13 @@ std::string InferAuthType( return AuthProperties::kAuthTypeNone; } -AuthManagerRegistry CreateDefaultRegistry() { - return { +AuthManagerRegistry& GetRegistry() { + static AuthManagerRegistry registry = { {AuthProperties::kAuthTypeNone, MakeNoopAuthManager}, {AuthProperties::kAuthTypeBasic, MakeBasicAuthManager}, {AuthProperties::kAuthTypeOAuth2, MakeOAuth2Manager}, + {AuthProperties::kAuthTypeSigV4, MakeSigV4AuthManager}, }; -} - -// Get the global registry of auth manager factories. -AuthManagerRegistry& GetRegistry() { - static AuthManagerRegistry registry = CreateDefaultRegistry(); return registry; } diff --git a/src/iceberg/catalog/rest/auth/auth_properties.h b/src/iceberg/catalog/rest/auth/auth_properties.h index 05a7ea2c6..a699569c1 100644 --- a/src/iceberg/catalog/rest/auth/auth_properties.h +++ b/src/iceberg/catalog/rest/auth/auth_properties.h @@ -54,11 +54,22 @@ class ICEBERG_REST_EXPORT AuthProperties : public ConfigBase { // ---- SigV4 entries ---- - inline static const std::string kSigV4Region = "rest.auth.sigv4.region"; - inline static const std::string kSigV4Service = "rest.auth.sigv4.service"; + /// Deprecated: `rest.sigv4-enabled=true` selects SigV4 regardless of + /// `rest.auth.type`. + inline static const std::string kSigV4Enabled = "rest.sigv4-enabled"; inline static const std::string kSigV4DelegateAuthType = "rest.auth.sigv4.delegate-auth-type"; + /// SigV4 signing region. If unset, SigV4 resolves the signing region from + /// AWS environment/profile configuration and fails if no region can be + /// resolved. + inline static const std::string kSigV4SigningRegion = "rest.signing-region"; + inline static const std::string kSigV4SigningName = "rest.signing-name"; + inline static const std::string kSigV4SigningNameDefault = "execute-api"; + inline static const std::string kSigV4AccessKeyId = "rest.access-key-id"; + inline static const std::string kSigV4SecretAccessKey = "rest.secret-access-key"; + inline static const std::string kSigV4SessionToken = "rest.session-token"; + // ---- OAuth2 entries ---- inline static Entry kToken{"token", ""}; diff --git a/src/iceberg/catalog/rest/auth/auth_session.cc b/src/iceberg/catalog/rest/auth/auth_session.cc index 31688eedf..545ee00b1 100644 --- a/src/iceberg/catalog/rest/auth/auth_session.cc +++ b/src/iceberg/catalog/rest/auth/auth_session.cc @@ -43,11 +43,11 @@ class DefaultAuthSession : public AuthSession { explicit DefaultAuthSession(std::unordered_map headers) : headers_(std::move(headers)) {} - Status Authenticate(std::unordered_map& headers) override { + Result Authenticate(HttpRequest request) override { for (const auto& [key, value] : headers_) { - headers.try_emplace(key, value); + request.headers.try_emplace(key, value); } - return {}; + return request; } private: @@ -77,12 +77,12 @@ class OAuth2AuthSession : public AuthSession, return session; } - Status Authenticate(std::unordered_map& headers) override { + Result Authenticate(HttpRequest request) override { std::shared_lock lock(mutex_); for (const auto& [key, value] : headers_) { - headers.try_emplace(key, value); + request.headers.try_emplace(key, value); } - return {}; + return request; } Status Close() override { return CloseImpl(); } diff --git a/src/iceberg/catalog/rest/auth/auth_session.h b/src/iceberg/catalog/rest/auth/auth_session.h index 5cccacec9..3d0063a04 100644 --- a/src/iceberg/catalog/rest/auth/auth_session.h +++ b/src/iceberg/catalog/rest/auth/auth_session.h @@ -23,6 +23,7 @@ #include #include +#include "iceberg/catalog/rest/http_request.h" #include "iceberg/catalog/rest/iceberg_rest_export.h" #include "iceberg/catalog/rest/type_fwd.h" #include "iceberg/result.h" @@ -37,20 +38,21 @@ class ICEBERG_REST_EXPORT AuthSession { public: virtual ~AuthSession() = default; - /// \brief Authenticate the given request headers. + /// \brief Authenticate an outgoing HTTP request. /// - /// This method adds authentication information (e.g., Authorization header) - /// to the provided headers map. The implementation should be idempotent. + /// Returns a request with authentication information (e.g., an Authorization + /// header) added. Implementations must be idempotent. The request is passed + /// by value so callers can move request bodies into the authentication path. /// - /// \param[in,out] headers The headers map to add authentication information to. - /// \return Status indicating success or one of the following errors: + /// \param request The request to authenticate. + /// \return The authenticated request on success, or one of: /// - AuthenticationFailed: General authentication failure (invalid credentials, /// etc.) /// - TokenExpired: Authentication token has expired and needs refresh /// - NotAuthorized: Not authenticated (401) /// - IOError: Network or connection errors when reaching auth server /// - RestError: HTTP errors from authentication service - virtual Status Authenticate(std::unordered_map& headers) = 0; + virtual Result Authenticate(HttpRequest request) = 0; /// \brief Close the session and release any resources. /// diff --git a/src/iceberg/catalog/rest/auth/sigv4_auth_manager_internal.h b/src/iceberg/catalog/rest/auth/sigv4_auth_manager_internal.h new file mode 100644 index 000000000..a4f42875d --- /dev/null +++ b/src/iceberg/catalog/rest/auth/sigv4_auth_manager_internal.h @@ -0,0 +1,149 @@ +/* + * 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. + */ + +#pragma once + +#include +#include +#include + +#include "iceberg/catalog/rest/auth/auth_manager.h" +#include "iceberg/catalog/rest/auth/auth_session.h" +#include "iceberg/catalog/rest/iceberg_rest_export.h" +#include "iceberg/result.h" + +namespace Aws::Auth { +class AWSCredentialsProvider; +} // namespace Aws::Auth + +namespace Aws::Client { +class AWSAuthV4Signer; +} // namespace Aws::Client + +namespace iceberg::rest::auth { + +/// \brief Initialize the AWS SDK for SigV4 use. Idempotent. +/// +/// Normal REST SigV4 users do not need to call this. SigV4 sessions lazily +/// initialize the SDK when needed. This hook exists for tests and for explicit +/// process-shutdown sequencing when an embedding application needs it. +ICEBERG_REST_EXPORT Status InitializeAwsSdk(); + +/// \brief Shut down the SigV4-owned AWS SDK lifecycle. +/// +/// Refuses if any SigV4 sessions are alive. +ICEBERG_REST_EXPORT Status FinalizeAwsSdk(); + +ICEBERG_REST_EXPORT bool IsAwsSdkInitialized(); +ICEBERG_REST_EXPORT bool IsAwsSdkFinalized(); + +/// \brief An AuthSession that signs requests with AWS SigV4. +/// +/// The request is first authenticated by the delegate AuthSession (e.g., OAuth2), +/// then signed with SigV4. In case of conflicting headers, the Authorization header +/// set by the delegate is relocated with an "Original-" prefix, then included in +/// the canonical headers to sign. +/// +/// See https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_sigv.html +/// +/// Thread safety: Authenticate() is thread-safe as long as the delegate +/// session is. +class ICEBERG_REST_EXPORT SigV4AuthSession : public AuthSession { + public: + /// SHA-256 hash of empty string, used for requests with no body. + static constexpr std::string_view kEmptyBodySha256 = + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; + + /// Prefix prepended to relocated headers that conflict with SigV4-signed headers. + static constexpr std::string_view kRelocatedHeaderPrefix = "Original-"; + + /// \brief Creates a session registered with the AWS SDK lifecycle. + /// + /// Fails if the SDK is not initialized. Every session owns its lifecycle + /// registration and unregisters on destruction. + static Result> Make( + std::shared_ptr delegate, std::string signing_region, + std::string signing_name, + std::shared_ptr credentials_provider); + + ~SigV4AuthSession() override; + + Result Authenticate(HttpRequest request) override; + + Status Close() override; + + const std::shared_ptr& delegate() const { return delegate_; } + + /// Exposed so derived sessions can reuse the chain instead of constructing + /// a fresh DefaultAWSCredentialsProviderChain per derivation. + const std::shared_ptr& credentials_provider() const { + return credentials_provider_; + } + + private: + SigV4AuthSession( + std::shared_ptr delegate, std::string signing_region, + std::string signing_name, + std::shared_ptr credentials_provider); + + std::shared_ptr delegate_; + std::string signing_region_; + std::string signing_name_; + std::shared_ptr credentials_provider_; + std::unique_ptr signer_; +}; + +/// \brief An AuthManager that produces SigV4AuthSession instances. +/// +/// Wraps a delegate AuthManager to handle double authentication (e.g., OAuth2 + SigV4). +class ICEBERG_REST_EXPORT SigV4AuthManager : public AuthManager { + public: + explicit SigV4AuthManager(std::unique_ptr delegate); + ~SigV4AuthManager() override; + + Result> InitSession( + HttpClient& init_client, + const std::unordered_map& properties) override; + + Result> CatalogSession( + HttpClient& shared_client, + const std::unordered_map& properties) override; + + Result> ContextualSession( + const std::unordered_map& context, + std::shared_ptr parent) override; + + Result> TableSession( + const TableIdentifier& table, + const std::unordered_map& properties, + std::shared_ptr parent) override; + + Status Close() override; + + private: + Result> WrapSession( + std::shared_ptr delegate_session, + const std::unordered_map& properties, + std::shared_ptr credentials_provider); + + std::unique_ptr delegate_; + std::unordered_map catalog_properties_; +}; + +} // namespace iceberg::rest::auth diff --git a/src/iceberg/catalog/rest/auth/sigv4_manager.cc b/src/iceberg/catalog/rest/auth/sigv4_manager.cc new file mode 100644 index 000000000..b3cb4dbd4 --- /dev/null +++ b/src/iceberg/catalog/rest/auth/sigv4_manager.cc @@ -0,0 +1,520 @@ +/* + * 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. + */ + +#include "iceberg/catalog/rest/auth/auth_manager_internal.h" +#include "iceberg/catalog/rest/auth/sigv4_auth_manager_internal.h" +#include "iceberg/result.h" + +#if ICEBERG_SIGV4_ENABLED + +# include +# include +# include +# include +# include +# include + +# include +# include +# include +# include +# include +# include +# include +# include +# include + +# include "iceberg/catalog/rest/auth/auth_managers.h" +# include "iceberg/catalog/rest/auth/auth_properties.h" +# include "iceberg/catalog/rest/auth/oauth2_util.h" +# include "iceberg/util/macros.h" +# include "iceberg/util/string_util.h" + +namespace iceberg::rest::auth { + +namespace { + +constexpr std::string_view kAmzContentSha256Header = "x-amz-content-sha256"; + +class AwsSdkLifecycle { + public: + static AwsSdkLifecycle& Instance() { + static AwsSdkLifecycle instance; + return instance; + } + + Status Initialize() { + std::lock_guard lock(mutex_); + auto s = state_.load(); + if (s == State::kInitialized) return {}; + if (s == State::kFinalized) { + return InvalidArgument("AWS SDK has already been finalized; cannot reinitialize"); + } + Aws::InitAPI(options_); + state_.store(State::kInitialized); + return {}; + } + + Status Finalize() { + std::lock_guard lock(mutex_); + if (state_.load() != State::kInitialized) return {}; + if (active_session_count_ != 0) { + return Invalid( + "Cannot finalize AWS SDK while {} SigV4 auth session(s) are still alive", + active_session_count_); + } + Aws::ShutdownAPI(options_); + state_.store(State::kFinalized); + return {}; + } + + Status EnsureInitialized() { + if (state_.load() == State::kInitialized) return {}; + return Initialize(); + } + + bool IsInitialized() const { return state_.load() == State::kInitialized; } + bool IsFinalized() const { return state_.load() == State::kFinalized; } + + // Holds the mutex while incrementing, so Finalize() can never observe a + // stale 0 between its count check and Aws::ShutdownAPI. + Status RegisterSession() { + std::lock_guard lock(mutex_); + if (state_.load() != State::kInitialized) { + return InvalidArgument( + "AWS SDK is not initialized; cannot create a SigV4AuthSession"); + } + ++active_session_count_; + return {}; + } + + void UnregisterSession() { + std::lock_guard lock(mutex_); + --active_session_count_; + } + + private: + enum class State : uint8_t { kUninitialized, kInitialized, kFinalized }; + + AwsSdkLifecycle() = default; + + std::atomic state_{State::kUninitialized}; + std::mutex mutex_; + Aws::SDKOptions options_; + size_t active_session_count_{0}; // guarded by mutex_ +}; + +Aws::Http::HttpMethod ToAwsMethod(HttpMethod method) { + switch (method) { + case HttpMethod::kGet: + return Aws::Http::HttpMethod::HTTP_GET; + case HttpMethod::kPost: + return Aws::Http::HttpMethod::HTTP_POST; + case HttpMethod::kPut: + return Aws::Http::HttpMethod::HTTP_PUT; + case HttpMethod::kDelete: + return Aws::Http::HttpMethod::HTTP_DELETE; + case HttpMethod::kHead: + return Aws::Http::HttpMethod::HTTP_HEAD; + } + return Aws::Http::HttpMethod::HTTP_GET; +} + +std::unordered_map MergeProperties( + const std::unordered_map& base, + const std::unordered_map& overrides) { + auto merged = base; + for (const auto& [key, value] : overrides) { + merged.insert_or_assign(key, value); + } + return merged; +} + +/// Matches Java RESTSigV4AuthSession: canonical headers carry +/// Base64(SHA256(body)), canonical request trailer uses hex. +class RestSigV4Signer : public Aws::Client::AWSAuthV4Signer { + public: + RestSigV4Signer(const std::shared_ptr& creds, + const char* service_name, const Aws::String& region) + : Aws::Client::AWSAuthV4Signer(creds, service_name, region, + PayloadSigningPolicy::Always, + /*urlEscapePath=*/false) { + // Skip the signer's hex overwrite of x-amz-content-sha256 so canonical + // headers see the caller's Base64; ComputePayloadHash still feeds hex + // into the canonical request trailer. + m_includeSha256HashHeader = false; + } +}; + +// TODO(sigv4): support loading a custom AWSCredentialsProvider via a class +// name property, matching Java's AwsProperties.restCredentialsProvider(). +Result> MakeCredentialsProvider( + const std::unordered_map& properties) { + auto access_key_it = properties.find(AuthProperties::kSigV4AccessKeyId); + auto secret_key_it = properties.find(AuthProperties::kSigV4SecretAccessKey); + auto session_token_it = properties.find(AuthProperties::kSigV4SessionToken); + bool has_ak = access_key_it != properties.end() && !access_key_it->second.empty(); + bool has_sk = secret_key_it != properties.end() && !secret_key_it->second.empty(); + bool has_token = + session_token_it != properties.end() && !session_token_it->second.empty(); + + ICEBERG_PRECHECK( + has_ak == has_sk, "Both '{}' and '{}' must be set together, or neither", + AuthProperties::kSigV4AccessKeyId, AuthProperties::kSigV4SecretAccessKey); + ICEBERG_PRECHECK(!has_token || (has_ak && has_sk), + "'{}' requires both '{}' and '{}' to be set", + AuthProperties::kSigV4SessionToken, AuthProperties::kSigV4AccessKeyId, + AuthProperties::kSigV4SecretAccessKey); + + if (has_ak) { + Aws::Auth::AWSCredentials credentials(access_key_it->second.c_str(), + secret_key_it->second.c_str()); + if (has_token) { + credentials.SetSessionToken(session_token_it->second.c_str()); + } + return std::make_shared(credentials); + } + + return std::make_shared(); +} + +Result ResolveSigningRegion( + const std::unordered_map& properties) { + if (auto it = properties.find(AuthProperties::kSigV4SigningRegion); + it != properties.end() && !it->second.empty()) { + return it->second; + } + // Resolve from env then the shared config profile, otherwise fail. + // If this becomes expensive, cache it at the catalog/AuthManager scope or + // introduce an AwsProperties-like object as Java does. + Aws::String region = Aws::Environment::GetEnv("AWS_REGION"); + if (region.empty()) { + region = Aws::Environment::GetEnv("AWS_DEFAULT_REGION"); + } + if (region.empty()) { + const auto& profiles = Aws::Config::GetCachedConfigProfiles(); + if (auto it = profiles.find(Aws::Auth::GetConfigProfileName()); + it != profiles.end()) { + region = it->second.GetRegion(); + } + } + if (region.empty()) { + return InvalidArgument( + "SigV4: could not resolve a signing region; set the '{}' property or the " + "AWS_REGION environment variable", + AuthProperties::kSigV4SigningRegion); + } + return std::string(region.c_str()); +} + +std::string ResolveSigningName( + const std::unordered_map& properties) { + if (auto it = properties.find(AuthProperties::kSigV4SigningName); + it != properties.end() && !it->second.empty()) { + return it->second; + } + return AuthProperties::kSigV4SigningNameDefault; +} + +bool HasSigV4CredentialOverride( + const std::unordered_map& properties) { + return properties.contains(AuthProperties::kSigV4AccessKeyId) || + properties.contains(AuthProperties::kSigV4SecretAccessKey) || + properties.contains(AuthProperties::kSigV4SessionToken); +} + +Result> ResolveCredentialsProvider( + const std::unordered_map& properties, + std::shared_ptr reuse_credentials = nullptr) { + if (reuse_credentials && !HasSigV4CredentialOverride(properties)) { + return reuse_credentials; + } + return MakeCredentialsProvider(properties); +} + +template +class ScopeExit { + public: + explicit ScopeExit(Fn fn) : fn_(std::move(fn)) {} + ScopeExit(ScopeExit&& other) noexcept + : fn_(std::move(other.fn_)), active_(other.active_) { + other.active_ = false; + } + ScopeExit(const ScopeExit&) = delete; + ScopeExit& operator=(const ScopeExit&) = delete; + ScopeExit& operator=(ScopeExit&&) = delete; + ~ScopeExit() { + if (active_) fn_(); + } + void Cancel() noexcept { active_ = false; } + + private: + Fn fn_; + bool active_ = true; +}; + +} // namespace + +// ---- SigV4AuthSession ---- + +SigV4AuthSession::SigV4AuthSession( + std::shared_ptr delegate, std::string signing_region, + std::string signing_name, + std::shared_ptr credentials_provider) + : delegate_(std::move(delegate)), + signing_region_(std::move(signing_region)), + signing_name_(std::move(signing_name)), + credentials_provider_(std::move(credentials_provider)), + signer_(std::make_unique( + credentials_provider_, signing_name_.c_str(), signing_region_.c_str())) {} + +SigV4AuthSession::~SigV4AuthSession() { AwsSdkLifecycle::Instance().UnregisterSession(); } + +Result SigV4AuthSession::Authenticate(HttpRequest request) { + ICEBERG_ASSIGN_OR_RAISE(auto delegate_request, + delegate_->Authenticate(std::move(request))); + const auto& original_headers = delegate_request.headers; + + std::unordered_map signing_headers; + for (const auto& [name, value] : original_headers) { + if (StringUtils::EqualsIgnoreCase(name, kAuthorizationHeader)) { + signing_headers[std::string(kRelocatedHeaderPrefix) + name] = value; + } else { + signing_headers[name] = value; + } + } + + Aws::Http::URI aws_uri(delegate_request.url.c_str()); + auto aws_request = std::make_shared( + aws_uri, ToAwsMethod(delegate_request.method)); + for (const auto& [name, value] : signing_headers) { + aws_request->SetHeaderValue(Aws::String(name.c_str()), Aws::String(value.c_str())); + } + + // Empty bodies use the hex SHA256 constant; non-empty bodies use + // Base64(SHA256(body)). This matches Java RESTSigV4AuthSession behavior. + if (delegate_request.body.empty()) { + aws_request->SetHeaderValue(Aws::String(kAmzContentSha256Header), + Aws::String(kEmptyBodySha256)); + } else { + auto body_stream = + Aws::MakeShared("SigV4Body", delegate_request.body); + aws_request->AddContentBody(body_stream); + auto sha256 = Aws::Utils::HashingUtils::CalculateSHA256( + Aws::String(delegate_request.body.data(), delegate_request.body.size())); + aws_request->SetHeaderValue(Aws::String(kAmzContentSha256Header), + Aws::Utils::HashingUtils::Base64Encode(sha256)); + } + + if (!signer_->SignRequest(*aws_request)) { + return AuthenticationFailed("AWS SigV4 request signing failed"); + } + + // Build a case-insensitive view of original headers so signer-added headers + // can be compared without lowercasing or copying the originals. + std::map + originals_by_name; + for (const auto& [orig_name, orig_value] : original_headers) { + originals_by_name.emplace(orig_name, orig_value); + } + + HttpRequest signed_request{.method = delegate_request.method, + .url = std::move(delegate_request.url), + .headers = {}, + .body = std::move(delegate_request.body)}; + for (const auto& [aws_name, aws_value] : aws_request->GetHeaders()) { + std::string name(aws_name.c_str(), aws_name.size()); + std::string value(aws_value.c_str(), aws_value.size()); + if (auto it = originals_by_name.find(std::string_view(name)); + it != originals_by_name.end()) { + // Preserve the original value when the signer overwrites a header. + if (it->second != std::string_view(value)) { + signed_request.headers.try_emplace(std::string(kRelocatedHeaderPrefix) + name, + std::string(it->second)); + } + } + signed_request.headers.insert_or_assign(std::move(name), std::move(value)); + } + + return signed_request; +} + +Status SigV4AuthSession::Close() { return delegate_->Close(); } + +// ---- SigV4AuthManager ---- + +SigV4AuthManager::SigV4AuthManager(std::unique_ptr delegate) + : delegate_(std::move(delegate)) {} + +SigV4AuthManager::~SigV4AuthManager() = default; + +Result> SigV4AuthManager::InitSession( + HttpClient& init_client, + const std::unordered_map& properties) { + ICEBERG_RETURN_UNEXPECTED(AwsSdkLifecycle::Instance().EnsureInitialized()); + ICEBERG_ASSIGN_OR_RAISE(auto delegate_session, + delegate_->InitSession(init_client, properties)); + ICEBERG_ASSIGN_OR_RAISE(auto credentials, ResolveCredentialsProvider(properties)); + return WrapSession(std::move(delegate_session), properties, std::move(credentials)); +} + +Result> SigV4AuthManager::CatalogSession( + HttpClient& shared_client, + const std::unordered_map& properties) { + ICEBERG_RETURN_UNEXPECTED(AwsSdkLifecycle::Instance().EnsureInitialized()); + catalog_properties_ = properties; + ICEBERG_ASSIGN_OR_RAISE(auto delegate_session, + delegate_->CatalogSession(shared_client, properties)); + ICEBERG_ASSIGN_OR_RAISE(auto credentials, ResolveCredentialsProvider(properties)); + return WrapSession(std::move(delegate_session), properties, std::move(credentials)); +} + +Result> SigV4AuthManager::ContextualSession( + const std::unordered_map& context, + std::shared_ptr parent) { + auto sigv4_parent = std::dynamic_pointer_cast(std::move(parent)); + ICEBERG_PRECHECK(sigv4_parent != nullptr, + "SigV4AuthManager parent must be a SigV4AuthSession"); + + ICEBERG_ASSIGN_OR_RAISE(auto delegate_session, delegate_->ContextualSession( + context, sigv4_parent->delegate())); + + auto merged = MergeProperties(catalog_properties_, context); + ICEBERG_ASSIGN_OR_RAISE( + auto credentials, + ResolveCredentialsProvider(context, sigv4_parent->credentials_provider())); + return WrapSession(std::move(delegate_session), merged, std::move(credentials)); +} + +Result> SigV4AuthManager::TableSession( + const TableIdentifier& table, + const std::unordered_map& properties, + std::shared_ptr parent) { + auto sigv4_parent = std::dynamic_pointer_cast(std::move(parent)); + ICEBERG_PRECHECK(sigv4_parent != nullptr, + "SigV4AuthManager parent must be a SigV4AuthSession"); + + ICEBERG_ASSIGN_OR_RAISE( + auto delegate_session, + delegate_->TableSession(table, properties, sigv4_parent->delegate())); + + auto merged = MergeProperties(catalog_properties_, properties); + ICEBERG_ASSIGN_OR_RAISE( + auto credentials, + ResolveCredentialsProvider(properties, sigv4_parent->credentials_provider())); + return WrapSession(std::move(delegate_session), merged, std::move(credentials)); +} + +Status SigV4AuthManager::Close() { return delegate_->Close(); } + +Result> SigV4AuthSession::Make( + std::shared_ptr delegate, std::string signing_region, + std::string signing_name, + std::shared_ptr credentials_provider) { + ICEBERG_RETURN_UNEXPECTED(AwsSdkLifecycle::Instance().RegisterSession()); + ScopeExit unregister_on_failure( + [] { AwsSdkLifecycle::Instance().UnregisterSession(); }); + auto session = std::shared_ptr( + new SigV4AuthSession(std::move(delegate), std::move(signing_region), + std::move(signing_name), std::move(credentials_provider))); + // The session's destructor now owns the unregister. + unregister_on_failure.Cancel(); + return session; +} + +Result> SigV4AuthManager::WrapSession( + std::shared_ptr delegate_session, + const std::unordered_map& properties, + std::shared_ptr credentials) { + ICEBERG_ASSIGN_OR_RAISE(auto region, ResolveSigningRegion(properties)); + auto service = ResolveSigningName(properties); + + // Fail fast when the provider cannot resolve credentials (e.g. an empty + // default chain) instead of sending an effectively unsigned request later. + if (credentials->GetAWSCredentials().IsEmpty()) { + return AuthenticationFailed( + "SigV4: AWS credentials provider returned empty credentials; set '{}' and '{}' " + "or configure the AWS credentials chain", + AuthProperties::kSigV4AccessKeyId, AuthProperties::kSigV4SecretAccessKey); + } + ICEBERG_ASSIGN_OR_RAISE( + auto session, SigV4AuthSession::Make(std::move(delegate_session), std::move(region), + std::move(service), std::move(credentials))); + return session; +} + +Result> MakeSigV4AuthManager( + std::string_view name, + const std::unordered_map& properties) { + // Default to OAuth2 when delegate type is not specified. + std::string delegate_type = AuthProperties::kAuthTypeOAuth2; + if (auto it = properties.find(AuthProperties::kSigV4DelegateAuthType); + it != properties.end() && !it->second.empty()) { + delegate_type = StringUtils::ToLower(it->second); + } + + // Prevent circular delegation (sigv4 -> sigv4 -> ...). + ICEBERG_PRECHECK(delegate_type != AuthProperties::kAuthTypeSigV4, + "Cannot delegate a SigV4 auth manager to another SigV4 auth " + "manager (delegate_type='{}')", + delegate_type); + + auto delegate_props = properties; + delegate_props[AuthProperties::kAuthType] = delegate_type; + // Strip the legacy flag so the recursive Load doesn't bounce back to SigV4. + delegate_props.erase(AuthProperties::kSigV4Enabled); + ICEBERG_ASSIGN_OR_RAISE(auto delegate, AuthManagers::Load(name, delegate_props)); + return std::make_unique(std::move(delegate)); +} + +Status InitializeAwsSdk() { return AwsSdkLifecycle::Instance().Initialize(); } + +Status FinalizeAwsSdk() { return AwsSdkLifecycle::Instance().Finalize(); } + +bool IsAwsSdkInitialized() { return AwsSdkLifecycle::Instance().IsInitialized(); } + +bool IsAwsSdkFinalized() { return AwsSdkLifecycle::Instance().IsFinalized(); } + +} // namespace iceberg::rest::auth + +#else // !ICEBERG_SIGV4_ENABLED + +namespace iceberg::rest::auth { + +Result> MakeSigV4AuthManager( + std::string_view /*name*/, + const std::unordered_map& /*properties*/) { + return NotSupported( + "SigV4 authentication is not built; configure with -DICEBERG_SIGV4=ON"); +} + +Status InitializeAwsSdk() { + return NotSupported( + "SigV4 authentication is not built; configure with -DICEBERG_SIGV4=ON"); +} + +Status FinalizeAwsSdk() { return {}; } + +bool IsAwsSdkInitialized() { return false; } + +bool IsAwsSdkFinalized() { return false; } + +} // namespace iceberg::rest::auth + +#endif // ICEBERG_SIGV4_ENABLED diff --git a/src/iceberg/catalog/rest/endpoint.cc b/src/iceberg/catalog/rest/endpoint.cc index bf457c879..953a7f03f 100644 --- a/src/iceberg/catalog/rest/endpoint.cc +++ b/src/iceberg/catalog/rest/endpoint.cc @@ -24,22 +24,6 @@ namespace iceberg::rest { -constexpr std::string_view ToString(HttpMethod method) { - switch (method) { - case HttpMethod::kGet: - return "GET"; - case HttpMethod::kPost: - return "POST"; - case HttpMethod::kPut: - return "PUT"; - case HttpMethod::kDelete: - return "DELETE"; - case HttpMethod::kHead: - return "HEAD"; - } - return "UNKNOWN"; -} - Result Endpoint::Make(HttpMethod method, std::string_view path) { if (path.empty()) { return InvalidArgument("Endpoint cannot have empty path"); diff --git a/src/iceberg/catalog/rest/endpoint.h b/src/iceberg/catalog/rest/endpoint.h index fdcd2108e..9f51b43d4 100644 --- a/src/iceberg/catalog/rest/endpoint.h +++ b/src/iceberg/catalog/rest/endpoint.h @@ -22,6 +22,7 @@ #include #include +#include "iceberg/catalog/rest/http_request.h" #include "iceberg/catalog/rest/iceberg_rest_export.h" #include "iceberg/result.h" @@ -30,12 +31,6 @@ namespace iceberg::rest { -/// \brief HTTP method enumeration. -enum class HttpMethod : uint8_t { kGet, kPost, kPut, kDelete, kHead }; - -/// \brief Convert HttpMethod to string representation. -constexpr std::string_view ToString(HttpMethod method); - /// \brief An Endpoint is an immutable value object identifying a specific REST API /// operation. It consists of: /// - HTTP method (GET, POST, DELETE, etc.) diff --git a/src/iceberg/catalog/rest/http_client.cc b/src/iceberg/catalog/rest/http_client.cc index 2e383b0ae..609116eb8 100644 --- a/src/iceberg/catalog/rest/http_client.cc +++ b/src/iceberg/catalog/rest/http_client.cc @@ -19,6 +19,8 @@ #include "iceberg/catalog/rest/http_client.h" +#include + #include #include @@ -68,27 +70,57 @@ namespace { /// \brief Default error type for unparseable REST responses. constexpr std::string_view kRestExceptionType = "RESTException"; -/// \brief Prepare headers for an HTTP request. -Result BuildHeaders( - const std::unordered_map& request_headers, +/// \brief Merge default headers with per-request headers (per-request wins). +HttpHeaders MergeHeaders( const std::unordered_map& default_headers, - auth::AuthSession& session) { - std::unordered_map headers(default_headers); + const std::unordered_map& request_headers) { + HttpHeaders merged; + for (const auto& [key, val] : default_headers) { + merged.try_emplace(key, val); + } for (const auto& [key, val] : request_headers) { - headers.insert_or_assign(key, val); + merged[key] = val; } - ICEBERG_RETURN_UNEXPECTED(session.Authenticate(headers)); - return cpr::Header(headers.begin(), headers.end()); + return merged; } -/// \brief Converts a map of string key-value pairs to cpr::Parameters. -cpr::Parameters GetParameters( +cpr::Header ToCprHeader(const HttpRequest& request) { + return {request.headers.begin(), request.headers.end()}; +} + +/// \brief Append URL-encoded query parameters to a URL, sorted by key. +/// \param base_url must not already contain a query string. Callers pass query +/// parameters separately so authentication signs one unambiguous final URL. +Result AppendQueryString( + const std::string& base_url, const std::unordered_map& params) { - cpr::Parameters cpr_params; - for (const auto& [key, val] : params) { - cpr_params.Add({key, val}); + if (params.empty()) return base_url; + if (base_url.find('?') != std::string::npos) { + return InvalidArgument( + "HttpClient base URL must not contain a query string when query parameters " + "are passed separately: {}", + base_url); + } + std::map sorted(params.begin(), params.end()); + std::string url = base_url + "?"; + bool first = true; + for (const auto& [k, v] : sorted) { + if (!first) url += "&"; + ICEBERG_ASSIGN_OR_RAISE(auto ek, EncodeString(k)); + ICEBERG_ASSIGN_OR_RAISE(auto ev, EncodeString(v)); + url += ek + "=" + ev; + first = false; } - return cpr_params; + return url; +} + +Result AuthenticateRequest(auth::AuthSession& session, HttpMethod method, + std::string url, HttpHeaders headers, + std::string body = "") { + return session.Authenticate({.method = method, + .url = std::move(url), + .headers = std::move(headers), + .body = std::move(body)}); } /// \brief Checks if the HTTP status code indicates a successful response. @@ -149,10 +181,12 @@ Result HttpClient::Get( const std::string& path, const std::unordered_map& params, const std::unordered_map& headers, const ErrorHandler& error_handler, auth::AuthSession& session) { - ICEBERG_ASSIGN_OR_RAISE(auto all_headers, - BuildHeaders(headers, default_headers_, session)); - cpr::Response response = - cpr::Get(cpr::Url{path}, GetParameters(params), all_headers, *connection_pool_); + ICEBERG_ASSIGN_OR_RAISE(auto url, AppendQueryString(path, params)); + ICEBERG_ASSIGN_OR_RAISE(auto authenticated, + AuthenticateRequest(session, HttpMethod::kGet, std::move(url), + MergeHeaders(default_headers_, headers))); + cpr::Response response = cpr::Get(cpr::Url{authenticated.url}, + ToCprHeader(authenticated), *connection_pool_); ICEBERG_RETURN_UNEXPECTED(HandleFailureResponse(response, error_handler)); HttpResponse http_response; @@ -164,10 +198,13 @@ Result HttpClient::Post( const std::string& path, const std::string& body, const std::unordered_map& headers, const ErrorHandler& error_handler, auth::AuthSession& session) { - ICEBERG_ASSIGN_OR_RAISE(auto all_headers, - BuildHeaders(headers, default_headers_, session)); + ICEBERG_ASSIGN_OR_RAISE( + auto authenticated, + AuthenticateRequest(session, HttpMethod::kPost, path, + MergeHeaders(default_headers_, headers), body)); cpr::Response response = - cpr::Post(cpr::Url{path}, cpr::Body{body}, all_headers, *connection_pool_); + cpr::Post(cpr::Url{authenticated.url}, cpr::Body{authenticated.body}, + ToCprHeader(authenticated), *connection_pool_); ICEBERG_RETURN_UNEXPECTED(HandleFailureResponse(response, error_handler)); HttpResponse http_response; @@ -182,16 +219,22 @@ Result HttpClient::PostForm( const ErrorHandler& error_handler, auth::AuthSession& session) { std::unordered_map form_headers(headers); form_headers.insert_or_assign(kHeaderContentType, kMimeTypeFormUrlEncoded); - ICEBERG_ASSIGN_OR_RAISE(auto all_headers, - BuildHeaders(form_headers, default_headers_, session)); std::vector pair_list; pair_list.reserve(form_data.size()); for (const auto& [key, val] : form_data) { pair_list.emplace_back(key, val); } + // Sign the exact bytes cpr will put on the wire. + std::string encoded_body = + cpr::Payload(pair_list.begin(), pair_list.end()).GetContent(); + ICEBERG_ASSIGN_OR_RAISE( + auto authenticated, + AuthenticateRequest(session, HttpMethod::kPost, path, + MergeHeaders(default_headers_, form_headers), + std::move(encoded_body))); cpr::Response response = - cpr::Post(cpr::Url{path}, cpr::Payload(pair_list.begin(), pair_list.end()), - all_headers, *connection_pool_); + cpr::Post(cpr::Url{authenticated.url}, cpr::Body{authenticated.body}, + ToCprHeader(authenticated), *connection_pool_); ICEBERG_RETURN_UNEXPECTED(HandleFailureResponse(response, error_handler)); HttpResponse http_response; @@ -202,9 +245,11 @@ Result HttpClient::PostForm( Result HttpClient::Head( const std::string& path, const std::unordered_map& headers, const ErrorHandler& error_handler, auth::AuthSession& session) { - ICEBERG_ASSIGN_OR_RAISE(auto all_headers, - BuildHeaders(headers, default_headers_, session)); - cpr::Response response = cpr::Head(cpr::Url{path}, all_headers, *connection_pool_); + ICEBERG_ASSIGN_OR_RAISE(auto authenticated, + AuthenticateRequest(session, HttpMethod::kHead, path, + MergeHeaders(default_headers_, headers))); + cpr::Response response = cpr::Head(cpr::Url{authenticated.url}, + ToCprHeader(authenticated), *connection_pool_); ICEBERG_RETURN_UNEXPECTED(HandleFailureResponse(response, error_handler)); HttpResponse http_response; @@ -216,10 +261,13 @@ Result HttpClient::Delete( const std::string& path, const std::unordered_map& params, const std::unordered_map& headers, const ErrorHandler& error_handler, auth::AuthSession& session) { - ICEBERG_ASSIGN_OR_RAISE(auto all_headers, - BuildHeaders(headers, default_headers_, session)); - cpr::Response response = - cpr::Delete(cpr::Url{path}, GetParameters(params), all_headers, *connection_pool_); + ICEBERG_ASSIGN_OR_RAISE(auto url, AppendQueryString(path, params)); + ICEBERG_ASSIGN_OR_RAISE( + auto authenticated, + AuthenticateRequest(session, HttpMethod::kDelete, std::move(url), + MergeHeaders(default_headers_, headers))); + cpr::Response response = cpr::Delete(cpr::Url{authenticated.url}, + ToCprHeader(authenticated), *connection_pool_); ICEBERG_RETURN_UNEXPECTED(HandleFailureResponse(response, error_handler)); HttpResponse http_response; diff --git a/src/iceberg/catalog/rest/http_request.h b/src/iceberg/catalog/rest/http_request.h new file mode 100644 index 000000000..47419c361 --- /dev/null +++ b/src/iceberg/catalog/rest/http_request.h @@ -0,0 +1,93 @@ +/* + * 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. + */ + +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include "iceberg/catalog/rest/iceberg_rest_export.h" + +namespace iceberg::rest { + +/// \brief HTTP method enumeration. +enum class HttpMethod : uint8_t { kGet, kPost, kPut, kDelete, kHead }; + +/// \brief Convert HttpMethod to string representation. +constexpr std::string_view ToString(HttpMethod method) { + switch (method) { + case HttpMethod::kGet: + return "GET"; + case HttpMethod::kPost: + return "POST"; + case HttpMethod::kPut: + return "PUT"; + case HttpMethod::kDelete: + return "DELETE"; + case HttpMethod::kHead: + return "HEAD"; + } + return "UNKNOWN"; +} + +/// \brief Case-insensitive ordering for HTTP header names. +/// +/// HTTP header names are case-insensitive. This comparator also matches +/// cpr::Header's single-value map model. +struct CaseInsensitiveHeaderLess { + using is_transparent = void; + + bool operator()(std::string_view lhs, std::string_view rhs) const noexcept { + const auto min_size = lhs.size() < rhs.size() ? lhs.size() : rhs.size(); + for (std::size_t i = 0; i < min_size; ++i) { + auto left = static_cast(lhs[i]); + auto right = static_cast(rhs[i]); + const int lower_left = std::tolower(left); + const int lower_right = std::tolower(right); + if (lower_left < lower_right) return true; + if (lower_left > lower_right) return false; + } + return lhs.size() < rhs.size(); + } +}; + +/// \brief Single-value HTTP headers with case-insensitive names. +/// +/// Repeated outgoing headers are intentionally not represented here. The +/// SigV4 path signs headers through the AWS SDK request model, and the final +/// transport uses cpr::Header; both are single-value, map-like containers that +/// fold duplicate names. Keeping the REST request model single-value avoids +/// exposing repeated-header behavior that cannot survive signing or transport. +using HttpHeaders = std::map; + +/// \brief An outgoing HTTP request. Mirrors Java's HttpRequest so signing +/// implementations like SigV4 see method, url, headers, and body together. +struct ICEBERG_REST_EXPORT HttpRequest { + HttpMethod method = HttpMethod::kGet; + std::string url; + HttpHeaders headers; + std::string body; +}; + +} // namespace iceberg::rest diff --git a/src/iceberg/catalog/rest/meson.build b/src/iceberg/catalog/rest/meson.build index f3eae6d45..48254614f 100644 --- a/src/iceberg/catalog/rest/meson.build +++ b/src/iceberg/catalog/rest/meson.build @@ -41,17 +41,36 @@ cpr_needs_static = ( ) cpr_dep = dependency('cpr', static: cpr_needs_static) +iceberg_rest_sources += files('auth/sigv4_manager.cc') iceberg_rest_build_deps = [iceberg_dep, cpr_dep] +iceberg_rest_compile_defs = [] + +sigv4_opt = get_option('sigv4') +# Use the CMake config, not pkg-config: aws-cpp-sdk-core.pc Cflags force +# -std=c++11 -fno-exceptions, which would override the project's C++23 build. +aws_sdk_core_dep = dependency( + 'aws-cpp-sdk-core', + method: 'cmake', + modules: ['aws-cpp-sdk-core'], + required: sigv4_opt, +) +if aws_sdk_core_dep.found() + iceberg_rest_build_deps += aws_sdk_core_dep + iceberg_rest_compile_defs += '-DICEBERG_SIGV4_ENABLED=1' +else + iceberg_rest_compile_defs += '-DICEBERG_SIGV4_ENABLED=0' +endif + iceberg_rest_lib = library( 'iceberg_rest', sources: iceberg_rest_sources, dependencies: iceberg_rest_build_deps, gnu_symbol_visibility: 'hidden', - cpp_shared_args: ['-DICEBERG_REST_EXPORTING'], - cpp_static_args: ['-DICEBERG_REST_STATIC'], + cpp_shared_args: ['-DICEBERG_REST_EXPORTING'] + iceberg_rest_compile_defs, + cpp_static_args: ['-DICEBERG_REST_STATIC'] + iceberg_rest_compile_defs, ) -iceberg_rest_compile_args = [] +iceberg_rest_compile_args = iceberg_rest_compile_defs if get_option('default_library') == 'static' iceberg_rest_compile_args += ['-DICEBERG_REST_STATIC'] endif @@ -70,6 +89,7 @@ install_headers( 'endpoint.h', 'error_handlers.h', 'http_client.h', + 'http_request.h', 'iceberg_rest_export.h', 'resource_paths.h', 'rest_catalog.h', diff --git a/src/iceberg/catalog/rest/rest_catalog.cc b/src/iceberg/catalog/rest/rest_catalog.cc index f04f5fb55..6472adc4b 100644 --- a/src/iceberg/catalog/rest/rest_catalog.cc +++ b/src/iceberg/catalog/rest/rest_catalog.cc @@ -355,7 +355,11 @@ Result RestCatalog::CreateTableInternal( *catalog_session_)); ICEBERG_ASSIGN_OR_RAISE(auto json, FromJsonString(response.body())); - return LoadTableResultFromJson(json); + ICEBERG_ASSIGN_OR_RAISE(auto load_result, LoadTableResultFromJson(json)); + // TODO: Wire table-specific auth config from LoadTableResponse once C++ has + // table-scoped REST operations or a table-scoped catalog wrapper. The current + // Table implementation routes refresh and commit back through Catalog. + return load_result; } Result> RestCatalog::CreateTable( @@ -479,7 +483,8 @@ Result> RestCatalog::LoadTable(const TableIdentifier& ide ICEBERG_ASSIGN_OR_RAISE(const auto body, LoadTableInternal(identifier)); ICEBERG_ASSIGN_OR_RAISE(auto json, FromJsonString(body)); ICEBERG_ASSIGN_OR_RAISE(auto load_result, LoadTableResultFromJson(json)); - /// FIXME: support per-table FileIO creation + // TODO: Support table-specific auth config and per-table FileIO from the REST + // load response when table-scoped REST operations are introduced. return Table::Make(identifier, std::move(load_result.metadata), std::move(load_result.metadata_location), file_io_, shared_from_this()); @@ -503,6 +508,8 @@ Result> RestCatalog::RegisterTable( ICEBERG_ASSIGN_OR_RAISE(auto json, FromJsonString(response.body())); ICEBERG_ASSIGN_OR_RAISE(auto load_result, LoadTableResultFromJson(json)); + // TODO: Support table-specific auth config and per-table FileIO from the REST + // register response when table-scoped REST operations are introduced. return Table::Make(identifier, std::move(load_result.metadata), std::move(load_result.metadata_location), file_io_, shared_from_this()); diff --git a/src/iceberg/iceberg-config.cmake.in b/src/iceberg/iceberg-config.cmake.in index 0339ee1a9..dfb0e1dbc 100644 --- a/src/iceberg/iceberg-config.cmake.in +++ b/src/iceberg/iceberg-config.cmake.in @@ -38,6 +38,9 @@ set(ICEBERG_BUILD_STATIC "@ICEBERG_BUILD_STATIC@") set(ICEBERG_SYSTEM_DEPENDENCIES "@ICEBERG_SYSTEM_DEPENDENCIES@") +# Extra args forwarded to find_dependency() for specific dependencies. +set(ICEBERG_FIND_EXTRA_ARGS_AWSSDK "@ICEBERG_FIND_EXTRA_ARGS_AWSSDK@") + include(CMakeFindDependencyMacro) macro(iceberg_find_dependencies dependencies) @@ -49,7 +52,7 @@ macro(iceberg_find_dependencies dependencies) set(CMAKE_MODULE_PATH "${CMAKE_CURRENT_LIST_DIR}") foreach(dependency ${dependencies}) - find_dependency(${dependency}) + find_dependency(${dependency} ${ICEBERG_FIND_EXTRA_ARGS_${dependency}}) endforeach() if(DEFINED ICEBERG_CMAKE_MODULE_PATH_OLD) diff --git a/src/iceberg/test/CMakeLists.txt b/src/iceberg/test/CMakeLists.txt index 2d56d7f35..0e8f03150 100644 --- a/src/iceberg/test/CMakeLists.txt +++ b/src/iceberg/test/CMakeLists.txt @@ -290,6 +290,11 @@ if(ICEBERG_BUILD_REST) rest_json_serde_test.cc rest_util_test.cc) + if(ICEBERG_SIGV4) + add_rest_iceberg_test(sigv4_auth_test SOURCES sigv4_auth_test.cc) + target_link_libraries(sigv4_auth_test PRIVATE aws-cpp-sdk-core) + endif() + if(ICEBERG_BUILD_REST_INTEGRATION_TESTS) add_rest_iceberg_test(rest_catalog_integration_test SOURCES diff --git a/src/iceberg/test/auth_manager_test.cc b/src/iceberg/test/auth_manager_test.cc index 40c0f8607..22ecef864 100644 --- a/src/iceberg/test/auth_manager_test.cc +++ b/src/iceberg/test/auth_manager_test.cc @@ -37,6 +37,7 @@ #include "iceberg/catalog/rest/auth/auth_session.h" #include "iceberg/catalog/rest/auth/oauth2_util.h" #include "iceberg/catalog/rest/auth/token_refresh_scheduler.h" +#include "iceberg/catalog/rest/error_handlers.h" #include "iceberg/catalog/rest/http_client.h" #include "iceberg/catalog/rest/json_serde_internal.h" #include "iceberg/json_serde_internal.h" @@ -78,9 +79,9 @@ TEST_F(AuthManagerTest, LoadNoopAuthManagerExplicit) { auto session_result = manager_result.value()->CatalogSession(client_, properties); ASSERT_THAT(session_result, IsOk()); - std::unordered_map headers; - EXPECT_THAT(session_result.value()->Authenticate(headers), IsOk()); - EXPECT_TRUE(headers.empty()); + auto auth_result = session_result.value()->Authenticate({}); + ASSERT_THAT(auth_result, IsOk()); + EXPECT_TRUE(auth_result.value().headers.empty()); } // Verifies that NoopAuthManager is inferred when no auth properties are set @@ -89,6 +90,25 @@ TEST_F(AuthManagerTest, LoadNoopAuthManagerInferred) { ASSERT_THAT(manager_result, IsOk()); } +TEST_F(AuthManagerTest, HttpHeadersAreCaseInsensitiveSingleValueMap) { + HttpHeaders headers; + headers.emplace("Authorization", "Bearer first"); + headers.emplace("authorization", "Bearer second"); + + EXPECT_EQ(headers.size(), 1); + EXPECT_EQ(headers.at("AUTHORIZATION"), "Bearer first"); +} + +TEST_F(AuthManagerTest, HttpClientRejectsParamsWhenUrlAlreadyHasQuery) { + auto session = AuthSession::MakeDefault({}); + auto result = + client_.Get("http://127.0.0.1/v1/config?existing=true", {{"warehouse", "prod"}}, + /*headers=*/{}, *rest::DefaultErrorHandler::Instance(), *session); + + EXPECT_THAT(result, IsError(ErrorKind::kInvalidArgument)); + EXPECT_THAT(result, HasErrorMessage("must not contain a query string")); +} + // Verifies that auth type is case-insensitive TEST_F(AuthManagerTest, AuthTypeCaseInsensitive) { for (const auto& auth_type : {"NONE", "None", "NoNe"}) { @@ -122,10 +142,10 @@ TEST_F(AuthManagerTest, LoadBasicAuthManager) { auto session_result = manager_result.value()->CatalogSession(client_, properties); ASSERT_THAT(session_result, IsOk()); - std::unordered_map headers; - EXPECT_THAT(session_result.value()->Authenticate(headers), IsOk()); + auto auth_result = session_result.value()->Authenticate({}); + ASSERT_THAT(auth_result, IsOk()); // base64("admin:secret") == "YWRtaW46c2VjcmV0" - EXPECT_EQ(headers["Authorization"], "Basic YWRtaW46c2VjcmV0"); + EXPECT_EQ(auth_result.value().headers["Authorization"], "Basic YWRtaW46c2VjcmV0"); } // Verifies BasicAuthManager is case-insensitive for auth type @@ -141,10 +161,10 @@ TEST_F(AuthManagerTest, BasicAuthTypeCaseInsensitive) { auto session_result = manager_result.value()->CatalogSession(client_, properties); ASSERT_THAT(session_result, IsOk()) << "Failed for auth type: " << auth_type; - std::unordered_map headers; - EXPECT_THAT(session_result.value()->Authenticate(headers), IsOk()); + auto auth_result = session_result.value()->Authenticate({}); + ASSERT_THAT(auth_result, IsOk()) << "Failed for auth type: " << auth_type; // base64("user:pass") == "dXNlcjpwYXNz" - EXPECT_EQ(headers["Authorization"], "Basic dXNlcjpwYXNz"); + EXPECT_EQ(auth_result.value().headers["Authorization"], "Basic dXNlcjpwYXNz"); } } @@ -187,10 +207,11 @@ TEST_F(AuthManagerTest, BasicAuthSpecialCharacters) { auto session_result = manager_result.value()->CatalogSession(client_, properties); ASSERT_THAT(session_result, IsOk()); - std::unordered_map headers; - EXPECT_THAT(session_result.value()->Authenticate(headers), IsOk()); + auto auth_result = session_result.value()->Authenticate({}); + ASSERT_THAT(auth_result, IsOk()); // base64("user@domain.com:p@ss:w0rd!") == "dXNlckBkb21haW4uY29tOnBAc3M6dzByZCE=" - EXPECT_EQ(headers["Authorization"], "Basic dXNlckBkb21haW4uY29tOnBAc3M6dzByZCE="); + EXPECT_EQ(auth_result.value().headers["Authorization"], + "Basic dXNlckBkb21haW4uY29tOnBAc3M6dzByZCE="); } // Verifies custom auth manager registration @@ -219,9 +240,9 @@ TEST_F(AuthManagerTest, RegisterCustomAuthManager) { auto session_result = manager_result.value()->CatalogSession(client_, properties); ASSERT_THAT(session_result, IsOk()); - std::unordered_map headers; - EXPECT_THAT(session_result.value()->Authenticate(headers), IsOk()); - EXPECT_EQ(headers["X-Custom-Auth"], "custom-value"); + auto auth_result = session_result.value()->Authenticate({}); + ASSERT_THAT(auth_result, IsOk()); + EXPECT_EQ(auth_result.value().headers["X-Custom-Auth"], "custom-value"); } // Verifies OAuth2 with static token @@ -237,9 +258,9 @@ TEST_F(AuthManagerTest, OAuth2StaticToken) { auto session_result = manager_result.value()->CatalogSession(client_, properties); ASSERT_THAT(session_result, IsOk()); - std::unordered_map headers; - EXPECT_THAT(session_result.value()->Authenticate(headers), IsOk()); - EXPECT_EQ(headers["Authorization"], "Bearer my-static-token"); + auto auth_result = session_result.value()->Authenticate({}); + ASSERT_THAT(auth_result, IsOk()); + EXPECT_EQ(auth_result.value().headers["Authorization"], "Bearer my-static-token"); } // Verifies OAuth2 type is inferred from token property @@ -254,9 +275,9 @@ TEST_F(AuthManagerTest, OAuth2InferredFromToken) { auto session_result = manager_result.value()->CatalogSession(client_, properties); ASSERT_THAT(session_result, IsOk()); - std::unordered_map headers; - EXPECT_THAT(session_result.value()->Authenticate(headers), IsOk()); - EXPECT_EQ(headers["Authorization"], "Bearer inferred-token"); + auto auth_result = session_result.value()->Authenticate({}); + ASSERT_THAT(auth_result, IsOk()); + EXPECT_EQ(auth_result.value().headers["Authorization"], "Bearer inferred-token"); } // Verifies OAuth2 returns unauthenticated session when neither token nor credential is @@ -273,9 +294,10 @@ TEST_F(AuthManagerTest, OAuth2MissingCredentials) { ASSERT_THAT(session_result, IsOk()); // Session should have no auth headers - std::unordered_map headers; - ASSERT_TRUE(session_result.value()->Authenticate(headers).has_value()); - EXPECT_EQ(headers.find("Authorization"), headers.end()); + auto auth_result = session_result.value()->Authenticate({}); + ASSERT_TRUE(auth_result.has_value()); + EXPECT_EQ(auth_result.value().headers.find("Authorization"), + auth_result.value().headers.end()); } // Verifies that when both token and credential are provided, token takes priority @@ -294,9 +316,9 @@ TEST_F(AuthManagerTest, OAuth2TokenTakesPriorityOverCredential) { auto session_result = manager_result.value()->CatalogSession(client_, properties); ASSERT_THAT(session_result, IsOk()); - std::unordered_map headers; - ASSERT_THAT(session_result.value()->Authenticate(headers), IsOk()); - EXPECT_EQ(headers["Authorization"], "Bearer my-static-token"); + auto auth_result = session_result.value()->Authenticate({}); + ASSERT_THAT(auth_result, IsOk()); + EXPECT_EQ(auth_result.value().headers["Authorization"], "Bearer my-static-token"); } // Verifies OAuthTokenResponse JSON parsing @@ -494,9 +516,9 @@ TEST(OAuth2AuthSessionTest, InitialTokenIsUsed) { ASSERT_THAT(session_result, IsOk()); auto session = session_result.value(); - std::unordered_map headers; - ASSERT_THAT(session->Authenticate(headers), IsOk()); - EXPECT_EQ(headers["Authorization"], "Bearer initial-token-123"); + auto auth_result = session->Authenticate({}); + ASSERT_THAT(auth_result, IsOk()); + EXPECT_EQ(auth_result.value().headers.at("Authorization"), "Bearer initial-token-123"); session->Close(); } diff --git a/src/iceberg/test/meson.build b/src/iceberg/test/meson.build index 8d2805900..03d9e1f6c 100644 --- a/src/iceberg/test/meson.build +++ b/src/iceberg/test/meson.build @@ -135,6 +135,14 @@ if get_option('rest').enabled() 'dependencies': [iceberg_rest_dep], }, } + if aws_sdk_core_dep.found() + iceberg_tests += { + 'sigv4_auth_test': { + 'sources': files('sigv4_auth_test.cc'), + 'dependencies': [iceberg_rest_dep, aws_sdk_core_dep], + }, + } + endif if get_option('rest_integration_test').enabled() if host_machine.system() == 'windows' warning('Cannot build rest integration test on Windows, skipping.') diff --git a/src/iceberg/test/sigv4_auth_test.cc b/src/iceberg/test/sigv4_auth_test.cc new file mode 100644 index 000000000..ac8c36b63 --- /dev/null +++ b/src/iceberg/test/sigv4_auth_test.cc @@ -0,0 +1,512 @@ +/* + * 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. + */ + +#if ICEBERG_SIGV4_ENABLED + +# include +# include +# include +# include +# include +# include +# include + +# include +# include +# include +# include +# include + +# include "iceberg/catalog/rest/auth/auth_managers.h" +# include "iceberg/catalog/rest/auth/auth_properties.h" +# include "iceberg/catalog/rest/auth/auth_session.h" +# include "iceberg/catalog/rest/auth/sigv4_auth_manager_internal.h" +# include "iceberg/catalog/rest/http_client.h" +# include "iceberg/table_identifier.h" +# include "iceberg/test/matchers.h" + +namespace iceberg::rest::auth { + +namespace { + +using ::testing::HasSubstr; +using ::testing::StartsWith; + +constexpr std::string_view kAccessKey = "AKIAIOSFODNN7EXAMPLE"; +constexpr std::string_view kSecretKey = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"; +constexpr std::string_view kAmzContentSha256Header = "x-amz-content-sha256"; + +std::string HexEncode(const Aws::Utils::ByteBuffer& buffer) { + static constexpr char kHex[] = "0123456789abcdef"; + std::string hex; + hex.reserve(buffer.GetLength() * 2); + for (size_t i = 0; i < buffer.GetLength(); ++i) { + auto byte = buffer.GetUnderlyingData()[i]; + hex.push_back(kHex[byte >> 4]); + hex.push_back(kHex[byte & 0x0F]); + } + return hex; +} + +Aws::Utils::ByteBuffer BufferFromString(std::string_view value) { + return Aws::Utils::ByteBuffer(reinterpret_cast(value.data()), + value.size()); +} + +Aws::Utils::ByteBuffer HmacSha256(const Aws::Utils::ByteBuffer& key, + std::string_view value) { + Aws::Utils::Crypto::Sha256HMAC hmac; + auto result = hmac.Calculate(BufferFromString(value), key); + EXPECT_TRUE(result.IsSuccess()); + return result.GetResult(); +} + +std::string Sha256Hex(std::string_view value) { + auto digest = + Aws::Utils::HashingUtils::CalculateSHA256(Aws::String(value.data(), value.size())); + return Aws::Utils::HashingUtils::HexEncode(digest).c_str(); +} + +std::string ExtractAuthField(std::string_view authorization, std::string_view prefix) { + auto pos = authorization.find(prefix); + EXPECT_NE(pos, std::string_view::npos) << authorization; + if (pos == std::string_view::npos) return {}; + pos += prefix.size(); + auto end = authorization.find(',', pos); + return std::string(authorization.substr(pos, end - pos)); +} + +std::string HeaderValue(const HttpHeaders& headers, std::string_view name) { + auto it = headers.find(name); + EXPECT_NE(it, headers.end()) << "Missing header: " << name; + if (it == headers.end()) return {}; + return it->second; +} + +std::string PathFromUrl(const std::string& url) { + auto scheme = url.find("://"); + auto path_start = + scheme == std::string::npos ? url.find('/') : url.find('/', scheme + 3); + if (path_start == std::string::npos) return "/"; + auto query_start = url.find('?', path_start); + return url.substr(path_start, query_start - path_start); +} + +std::string CanonicalQueryFromUrl(const std::string& url) { + auto query_start = url.find('?'); + if (query_start == std::string::npos) return {}; + std::vector params; + size_t start = query_start + 1; + while (start <= url.size()) { + auto end = url.find('&', start); + params.emplace_back(url.substr(start, end - start)); + if (end == std::string::npos) break; + start = end + 1; + } + std::sort(params.begin(), params.end()); + + std::string canonical; + for (const auto& param : params) { + if (!canonical.empty()) canonical += '&'; + canonical += param; + } + return canonical; +} + +std::string ExpectedSigV4Signature(const HttpRequest& request, + std::string_view signing_region, + std::string_view signing_name) { + const auto authorization = HeaderValue(request.headers, "authorization"); + const auto x_amz_date = HeaderValue(request.headers, "x-amz-date"); + const auto credential_scope = + ExtractAuthField(authorization, std::string(kAccessKey) + "/"); + const auto signed_headers = ExtractAuthField(authorization, "SignedHeaders="); + const auto date = x_amz_date.substr(0, 8); + + std::string canonical_headers; + size_t start = 0; + while (start <= signed_headers.size()) { + auto end = signed_headers.find(';', start); + auto header_name = signed_headers.substr(start, end - start); + canonical_headers += header_name; + canonical_headers += ':'; + canonical_headers += HeaderValue(request.headers, header_name); + canonical_headers += '\n'; + if (end == std::string::npos) break; + start = end + 1; + } + + auto payload_hash = request.body.empty() + ? std::string(SigV4AuthSession::kEmptyBodySha256) + : Sha256Hex(request.body); + const auto canonical_request = + std::string(ToString(request.method)) + "\n" + PathFromUrl(request.url) + "\n" + + CanonicalQueryFromUrl(request.url) + "\n" + canonical_headers + "\n" + + signed_headers + "\n" + payload_hash; + const auto string_to_sign = "AWS4-HMAC-SHA256\n" + x_amz_date + "\n" + + credential_scope + "\n" + Sha256Hex(canonical_request); + + auto date_key = HmacSha256(BufferFromString("AWS4" + std::string(kSecretKey)), date); + auto region_key = HmacSha256(date_key, signing_region); + auto service_key = HmacSha256(region_key, signing_name); + auto signing_key = HmacSha256(service_key, "aws4_request"); + return HexEncode(HmacSha256(signing_key, string_to_sign)); +} + +} // namespace + +class SigV4AuthTest : public ::testing::Test { + protected: + static void SetUpTestSuite() { ASSERT_THAT(InitializeAwsSdk(), IsOk()); } + + static void TearDownTestSuite() { EXPECT_THAT(FinalizeAwsSdk(), IsOk()); } + + std::unordered_map MakeSigV4Properties() { + return { + {AuthProperties::kAuthType, "sigv4"}, + {AuthProperties::kSigV4SigningRegion, "us-east-1"}, + {AuthProperties::kSigV4SigningName, "execute-api"}, + {AuthProperties::kSigV4AccessKeyId, std::string(kAccessKey)}, + {AuthProperties::kSigV4SecretAccessKey, std::string(kSecretKey)}, + }; + } + + Result> MakeCatalogSession( + const std::unordered_map& properties) { + ICEBERG_ASSIGN_OR_RAISE(auto manager, AuthManagers::Load("test-catalog", properties)); + return manager->CatalogSession(client_, properties); + } + + Result SignRequest( + const std::unordered_map& properties, + HttpRequest request) { + ICEBERG_ASSIGN_OR_RAISE(auto session, MakeCatalogSession(properties)); + return session->Authenticate(std::move(request)); + } + + HttpClient client_{{}}; +}; + +TEST_F(SigV4AuthTest, LifecycleInitializeIsIdempotent) { + EXPECT_THAT(InitializeAwsSdk(), IsOk()); + EXPECT_TRUE(IsAwsSdkInitialized()); + EXPECT_FALSE(IsAwsSdkFinalized()); +} + +TEST_F(SigV4AuthTest, LifecycleFinalizeRefusesWhileSessionsAlive) { + ICEBERG_UNWRAP_OR_FAIL(auto session, MakeCatalogSession(MakeSigV4Properties())); + + EXPECT_THAT(FinalizeAwsSdk(), IsError(ErrorKind::kInvalid)); + EXPECT_TRUE(IsAwsSdkInitialized()); +} + +TEST_F(SigV4AuthTest, AuthenticateWithoutBodyDetailedHeaders) { + ICEBERG_UNWRAP_OR_FAIL(auto signed_request, + SignRequest(MakeSigV4Properties(), + {.method = HttpMethod::kGet, + .url = "http://localhost:8080/path", + .headers = {{"Content-Type", "application/json"}, + {"Content-Encoding", "gzip"}}})); + + EXPECT_EQ(HeaderValue(signed_request.headers, "content-type"), "application/json"); + EXPECT_EQ(HeaderValue(signed_request.headers, "content-encoding"), "gzip"); + EXPECT_EQ(HeaderValue(signed_request.headers, "host"), "localhost:8080"); + EXPECT_EQ(HeaderValue(signed_request.headers, kAmzContentSha256Header), + SigV4AuthSession::kEmptyBodySha256); + EXPECT_NE(signed_request.headers.find("x-amz-date"), signed_request.headers.end()); + + auto authorization = HeaderValue(signed_request.headers, "authorization"); + EXPECT_THAT(authorization, StartsWith("AWS4-HMAC-SHA256 Credential=")); + EXPECT_THAT(authorization, HasSubstr("SignedHeaders=content-encoding;content-type;host;" + "x-amz-content-sha256;x-amz-date")); + EXPECT_EQ(ExtractAuthField(authorization, "Signature="), + ExpectedSigV4Signature(signed_request, "us-east-1", "execute-api")); +} + +TEST_F(SigV4AuthTest, AuthenticateWithBodyDetailedHeaders) { + ICEBERG_UNWRAP_OR_FAIL( + auto signed_request, + SignRequest(MakeSigV4Properties(), + {.method = HttpMethod::kPost, + .url = "http://localhost:8080/path", + .headers = {{"Content-Type", "application/x-www-form-urlencoded"}, + {"Content-Encoding", "gzip"}}, + .body = R"({"namespace":["ns"]})"})); + + auto authorization = HeaderValue(signed_request.headers, "authorization"); + EXPECT_THAT(authorization, StartsWith("AWS4-HMAC-SHA256 Credential=")); + EXPECT_THAT(authorization, HasSubstr("SignedHeaders=content-encoding;content-type;host;" + "x-amz-content-sha256;x-amz-date")); + EXPECT_EQ(HeaderValue(signed_request.headers, kAmzContentSha256Header), + "LL0/LbCIE/WzVCHsfA3ASGOx9vJNPeTL0jBro8scPfA="); + EXPECT_EQ(ExtractAuthField(authorization, "Signature="), + ExpectedSigV4Signature(signed_request, "us-east-1", "execute-api")); +} + +TEST_F(SigV4AuthTest, QueryStringIsIncludedInSignature) { + ICEBERG_UNWRAP_OR_FAIL( + auto signed_request, + SignRequest(MakeSigV4Properties(), + {.method = HttpMethod::kGet, + .url = "http://localhost:8080/path?warehouse=prod&prefix=a"})); + + auto authorization = HeaderValue(signed_request.headers, "authorization"); + EXPECT_THAT(authorization, StartsWith("AWS4-HMAC-SHA256 Credential=")); + EXPECT_EQ(ExtractAuthField(authorization, "Signature="), + ExpectedSigV4Signature(signed_request, "us-east-1", "execute-api")); +} + +TEST_F(SigV4AuthTest, DelegateAuthorizationHeaderIsRelocatedAndSigned) { + auto properties = MakeSigV4Properties(); + properties[AuthProperties::kToken.key()] = "my-oauth-token"; + + ICEBERG_UNWRAP_OR_FAIL( + auto signed_request, + SignRequest(properties, {.method = HttpMethod::kGet, + .url = "http://localhost:8080/path", + .headers = {{"Content-Type", "application/json"}}})); + + EXPECT_EQ(HeaderValue(signed_request.headers, "original-authorization"), + "Bearer my-oauth-token"); + auto authorization = HeaderValue(signed_request.headers, "authorization"); + EXPECT_THAT(authorization, + HasSubstr("SignedHeaders=content-type;host;original-authorization;" + "x-amz-content-sha256;x-amz-date")); + EXPECT_EQ(ExtractAuthField(authorization, "Signature="), + ExpectedSigV4Signature(signed_request, "us-east-1", "execute-api")); +} + +TEST_F(SigV4AuthTest, ConflictingSigV4HeadersRelocated) { + auto delegate = AuthSession::MakeDefault({ + {"x-amz-content-sha256", "fake-sha256"}, + {"X-Amz-Date", "fake-date"}, + {"Content-Type", "application/json"}, + }); + auto credentials = + std::make_shared(Aws::Auth::AWSCredentials( + std::string(kAccessKey).c_str(), std::string(kSecretKey).c_str())); + ICEBERG_UNWRAP_OR_FAIL( + auto session, + SigV4AuthSession::Make(delegate, "us-east-1", "execute-api", credentials)); + + ICEBERG_UNWRAP_OR_FAIL(auto signed_request, + session->Authenticate({.method = HttpMethod::kGet, + .url = "http://localhost:8080/path"})); + + EXPECT_EQ(HeaderValue(signed_request.headers, kAmzContentSha256Header), + SigV4AuthSession::kEmptyBodySha256); + EXPECT_EQ(HeaderValue(signed_request.headers, "Original-x-amz-content-sha256"), + "fake-sha256"); + EXPECT_EQ(HeaderValue(signed_request.headers, "Original-X-Amz-Date"), "fake-date"); + EXPECT_NE(signed_request.headers.find("authorization"), signed_request.headers.end()); +} + +TEST_F(SigV4AuthTest, AuthenticateWithSessionToken) { + auto properties = MakeSigV4Properties(); + properties[AuthProperties::kSigV4SessionToken] = "FwoGZXIvYXdzEBYaDHqa0"; + + ICEBERG_UNWRAP_OR_FAIL( + auto signed_request, + SignRequest(properties, + {.method = HttpMethod::kGet, .url = "https://example.com/v1/config"})); + + EXPECT_EQ(HeaderValue(signed_request.headers, "x-amz-security-token"), + "FwoGZXIvYXdzEBYaDHqa0"); + EXPECT_THAT(HeaderValue(signed_request.headers, "authorization"), + HasSubstr("SignedHeaders=host;x-amz-content-sha256;x-amz-date;" + "x-amz-security-token")); +} + +TEST_F(SigV4AuthTest, CustomSigningNameAndRegion) { + auto properties = MakeSigV4Properties(); + properties[AuthProperties::kSigV4SigningRegion] = "eu-west-1"; + properties[AuthProperties::kSigV4SigningName] = "custom-service"; + + ICEBERG_UNWRAP_OR_FAIL( + auto signed_request, + SignRequest(properties, + {.method = HttpMethod::kGet, .url = "https://example.com/v1/config"})); + + auto authorization = HeaderValue(signed_request.headers, "authorization"); + EXPECT_THAT(authorization, HasSubstr("eu-west-1")); + EXPECT_THAT(authorization, HasSubstr("custom-service")); +} + +TEST_F(SigV4AuthTest, LegacySigV4EnabledFlagSelectsSigV4) { + auto properties = MakeSigV4Properties(); + properties.erase(AuthProperties::kAuthType); + properties[AuthProperties::kSigV4Enabled] = "true"; + + ICEBERG_UNWRAP_OR_FAIL( + auto signed_request, + SignRequest(properties, + {.method = HttpMethod::kGet, .url = "https://example.com/v1/config"})); + + EXPECT_THAT(HeaderValue(signed_request.headers, "authorization"), + StartsWith("AWS4-HMAC-SHA256")); +} + +TEST_F(SigV4AuthTest, AuthTypeCaseInsensitive) { + for (const auto& auth_type : {"SIGV4", "SigV4", "sigV4"}) { + auto properties = MakeSigV4Properties(); + properties[AuthProperties::kAuthType] = auth_type; + EXPECT_THAT(AuthManagers::Load("test-catalog", properties), IsOk()) + << "Failed for auth type: " << auth_type; + } +} + +TEST_F(SigV4AuthTest, MissingStaticCredentialsAreRejected) { + for (auto missing_property : + {AuthProperties::kSigV4AccessKeyId, AuthProperties::kSigV4SecretAccessKey}) { + auto properties = MakeSigV4Properties(); + properties.erase(missing_property); + ICEBERG_UNWRAP_OR_FAIL(auto manager, AuthManagers::Load("test-catalog", properties)); + + auto session_result = manager->CatalogSession(client_, properties); + EXPECT_THAT(session_result, IsError(ErrorKind::kInvalidArgument)) + << "Missing property: " << missing_property; + EXPECT_THAT(session_result, HasErrorMessage("must be set together")); + } + + auto session_token_only = MakeSigV4Properties(); + session_token_only.erase(AuthProperties::kSigV4AccessKeyId); + session_token_only.erase(AuthProperties::kSigV4SecretAccessKey); + session_token_only[AuthProperties::kSigV4SessionToken] = "token"; + ICEBERG_UNWRAP_OR_FAIL(auto manager, + AuthManagers::Load("test-catalog", session_token_only)); + + auto session_result = manager->CatalogSession(client_, session_token_only); + EXPECT_THAT(session_result, IsError(ErrorKind::kInvalidArgument)); + EXPECT_THAT(session_result, HasErrorMessage("requires")); +} + +TEST_F(SigV4AuthTest, DerivedCredentialOverridesMustBeComplete) { + auto properties = MakeSigV4Properties(); + ICEBERG_UNWRAP_OR_FAIL(auto manager, AuthManagers::Load("test-catalog", properties)); + ICEBERG_UNWRAP_OR_FAIL(auto catalog_session, + manager->CatalogSession(client_, properties)); + + auto context_result = manager->ContextualSession( + {{AuthProperties::kSigV4SecretAccessKey, "context-secret"}}, catalog_session); + EXPECT_THAT(context_result, IsError(ErrorKind::kInvalidArgument)); + EXPECT_THAT(context_result, HasErrorMessage("must be set together")); + + iceberg::TableIdentifier table_id{.ns = iceberg::Namespace{{"db1"}}, .name = "table1"}; + auto table_result = manager->TableSession( + table_id, {{AuthProperties::kSigV4SessionToken, "table-token"}}, catalog_session); + EXPECT_THAT(table_result, IsError(ErrorKind::kInvalidArgument)); + EXPECT_THAT(table_result, HasErrorMessage("requires")); +} + +TEST_F(SigV4AuthTest, CreateCustomDelegateNone) { + auto properties = MakeSigV4Properties(); + properties[AuthProperties::kSigV4DelegateAuthType] = "none"; + + ICEBERG_UNWRAP_OR_FAIL( + auto signed_request, + SignRequest(properties, + {.method = HttpMethod::kGet, .url = "https://example.com/v1/config"})); + + EXPECT_NE(signed_request.headers.find("authorization"), signed_request.headers.end()); + EXPECT_EQ(signed_request.headers.find("original-authorization"), + signed_request.headers.end()); +} + +TEST_F(SigV4AuthTest, CreateInvalidCustomDelegateSigV4Circular) { + auto properties = MakeSigV4Properties(); + properties[AuthProperties::kSigV4DelegateAuthType] = "sigv4"; + + auto result = AuthManagers::Load("test-catalog", properties); + EXPECT_THAT(result, IsError(ErrorKind::kInvalidArgument)); + EXPECT_THAT(result, + HasErrorMessage("Cannot delegate a SigV4 auth manager to another SigV4")); +} + +TEST_F(SigV4AuthTest, ContextualSessionOverridesProperties) { + auto properties = MakeSigV4Properties(); + properties[AuthProperties::kSigV4SigningRegion] = "us-west-2"; + ICEBERG_UNWRAP_OR_FAIL(auto manager, AuthManagers::Load("test-catalog", properties)); + ICEBERG_UNWRAP_OR_FAIL(auto catalog_session, + manager->CatalogSession(client_, properties)); + + ICEBERG_UNWRAP_OR_FAIL( + auto ctx_session, + manager->ContextualSession({{AuthProperties::kSigV4AccessKeyId, "id2"}, + {AuthProperties::kSigV4SecretAccessKey, "secret2"}, + {AuthProperties::kSigV4SigningRegion, "eu-west-1"}}, + catalog_session)); + + ICEBERG_UNWRAP_OR_FAIL( + auto signed_request, + ctx_session->Authenticate( + {.method = HttpMethod::kGet, .url = "https://example.com/v1/config"})); + EXPECT_THAT(HeaderValue(signed_request.headers, "authorization"), + HasSubstr("eu-west-1")); +} + +TEST_F(SigV4AuthTest, TableSessionOverridesProperties) { + auto properties = MakeSigV4Properties(); + properties[AuthProperties::kSigV4SigningRegion] = "us-west-2"; + ICEBERG_UNWRAP_OR_FAIL(auto manager, AuthManagers::Load("test-catalog", properties)); + ICEBERG_UNWRAP_OR_FAIL(auto catalog_session, + manager->CatalogSession(client_, properties)); + + iceberg::TableIdentifier table_id{.ns = iceberg::Namespace{{"db1"}}, .name = "table1"}; + ICEBERG_UNWRAP_OR_FAIL( + auto table_session, + manager->TableSession(table_id, + {{AuthProperties::kSigV4AccessKeyId, "table-key-id"}, + {AuthProperties::kSigV4SecretAccessKey, "table-secret"}, + {AuthProperties::kSigV4SigningRegion, "ap-southeast-1"}}, + catalog_session)); + + ICEBERG_UNWRAP_OR_FAIL( + auto signed_request, + table_session->Authenticate({.method = HttpMethod::kGet, + .url = "https://example.com/v1/db1/tables/table1"})); + EXPECT_THAT(HeaderValue(signed_request.headers, "authorization"), + HasSubstr("ap-southeast-1")); +} + +TEST_F(SigV4AuthTest, TableSessionIgnoresContextualOverrides) { + auto properties = MakeSigV4Properties(); + properties[AuthProperties::kSigV4SigningRegion] = "us-west-2"; + ICEBERG_UNWRAP_OR_FAIL(auto manager, AuthManagers::Load("test-catalog", properties)); + ICEBERG_UNWRAP_OR_FAIL(auto catalog_session, + manager->CatalogSession(client_, properties)); + ICEBERG_UNWRAP_OR_FAIL( + auto ctx_session, + manager->ContextualSession({{AuthProperties::kSigV4SigningRegion, "eu-west-1"}}, + catalog_session)); + + iceberg::TableIdentifier table_id{.ns = iceberg::Namespace{{"db1"}}, .name = "table1"}; + ICEBERG_UNWRAP_OR_FAIL(auto table_session, + manager->TableSession(table_id, {}, ctx_session)); + + ICEBERG_UNWRAP_OR_FAIL( + auto signed_request, + table_session->Authenticate({.method = HttpMethod::kGet, + .url = "https://example.com/v1/db1/tables/table1"})); + EXPECT_THAT(HeaderValue(signed_request.headers, "authorization"), + HasSubstr("us-west-2")); +} + +} // namespace iceberg::rest::auth + +#endif // ICEBERG_SIGV4_ENABLED