Skip to content

Commit 8df3b87

Browse files
Align SigV4 auth with Java: sign relocated Authorization, validate config, add docs
1 parent 8705057 commit 8df3b87

5 files changed

Lines changed: 124 additions & 0 deletions

File tree

mkdocs/docs/configuration.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -384,6 +384,8 @@ Legacy OAuth2 Properties will be removed in PyIceberg 1.0 in place of pluggable
384384
| rest.signing-region | us-east-1 | The region to use when SigV4 signing a request |
385385
| rest.signing-name | execute-api | The service signing name to use when SigV4 signing a request |
386386

387+
SigV4 can also be enabled as `auth.type: sigv4`, which additionally lets you choose the wrapped header-based auth (see the AuthManager section below).
388+
387389
##### Pluggable Authentication via AuthManager
388390

389391
The RESTCatalog supports pluggable authentication via the `auth` configuration block. This allows you to specify which how the access token will be fetched and managed for use with the HTTP requests to the RESTCatalog server. The authentication method is selected by setting the `auth.type` property, and additional configuration can be provided as needed for each method.
@@ -396,6 +398,7 @@ The RESTCatalog supports pluggable authentication via the `auth` configuration b
396398
- `custom`: Custom authentication manager (requires `auth.impl`).
397399
- `google`: Google Authentication support
398400
- `entra`: Microsoft Entra ID (Azure AD) authentication support
401+
- `sigv4`: AWS SigV4 request signing, optionally wrapping a delegate auth type.
399402

400403
###### Configuration Properties
401404

@@ -424,6 +427,7 @@ catalog:
424427
| `auth.custom` | If type is `custom` | Block containing configuration for the custom AuthManager. |
425428
| `auth.google` | If type is `google` | Block containing `credentials_path` to a service account file (if using). Will default to using Application Default Credentials. |
426429
| `auth.entra` | If type is `entra` | Block containing Entra ID configuration. Will default to using DefaultAzureCredential. |
430+
| `auth.sigv4` | If type is `sigv4` | Block containing an optional `delegate` auth block whose `Authorization` header is preserved as `Original-Authorization` after signing. Signing region/name come from `rest.signing-region`/`rest.signing-name`; AWS credentials from `client.*` or the standard boto3 chain. |
427431

428432
###### Examples
429433

@@ -469,6 +473,24 @@ auth:
469473
property2: value2
470474
```
471475

476+
SigV4 Signing (wrapping OAuth2):
477+
478+
```yaml
479+
auth:
480+
type: sigv4
481+
sigv4:
482+
delegate:
483+
type: oauth2
484+
oauth2:
485+
client_id: my-client-id
486+
client_secret: my-client-secret
487+
token_url: https://auth.example.com/oauth/token
488+
rest.signing-region: us-east-1
489+
rest.signing-name: execute-api
490+
client.access-key-id: my-access-key
491+
client.secret-access-key: my-secret-key
492+
```
493+
472494
###### Notes
473495

474496
- If `auth.type` is `custom`, you **must** specify `auth.impl` with the full class path to your custom AuthManager.

pyiceberg/catalog/rest/__init__.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -466,6 +466,12 @@ def _build_auth_manager(self, session: Session) -> AuthManager:
466466
"""Build the AuthManager, wrapping the delegate in SigV4 when enabled."""
467467
delegate = self._build_delegate_auth_manager(session)
468468
if self._is_sigv4_enabled():
469+
if property_as_bool(self.properties, SIGV4, False):
470+
deprecation_message(
471+
deprecated_in="0.11.0",
472+
removed_in="1.0.0",
473+
help_message=f"The property {SIGV4} is deprecated. Please use auth.type={SIGV4_AUTH_TYPE} instead",
474+
)
469475
return self._build_sigv4_auth_manager(delegate)
470476
return delegate
471477

@@ -477,13 +483,17 @@ def _build_delegate_auth_manager(self, session: Session) -> AuthManager:
477483
raise ValueError("auth.type must be defined")
478484

479485
if auth_type == SIGV4_AUTH_TYPE:
486+
if auth_config.get("impl"):
487+
raise ValueError("auth.impl can only be specified when using custom auth.type")
480488
# The delegate is configured under auth.sigv4.delegate.*
481489
sigv4_config = auth_config.get(SIGV4_AUTH_TYPE, {})
482490
delegate_config = sigv4_config.get("delegate")
483491
if not delegate_config or "type" not in delegate_config:
484492
# No delegate configured: SigV4-only auth, with no header-based delegate.
485493
return NoopAuthManager()
486494
delegate_type = delegate_config["type"]
495+
if delegate_type == SIGV4_AUTH_TYPE:
496+
raise ValueError("Cannot delegate a SigV4 auth manager to another SigV4 auth manager")
487497
return AuthManagerFactory.create(delegate_type, delegate_config.get(delegate_type, {}))
488498

489499
auth_type_config = auth_config.get(auth_type, {})

pyiceberg/catalog/rest/auth.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -415,6 +415,9 @@ def sign_request(self, request: PreparedRequest) -> PreparedRequest:
415415
content_sha256_header = EMPTY_BODY_SHA256
416416

417417
signing_headers = dict(request.headers)
418+
# Relocate Authorization before signing so it lands in SignedHeaders, like Java.
419+
if "Authorization" in signing_headers:
420+
signing_headers["Original-Authorization"] = signing_headers.pop("Authorization")
418421
signing_headers["x-amz-content-sha256"] = content_sha256_header
419422

420423
aws_request = AWSRequest(method=request.method, url=url, params=params, data=request.body, headers=signing_headers)

tests/catalog/test_rest.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -586,6 +586,9 @@ def test_list_tables_page_size(rest_mock: Mocker) -> None:
586586
]
587587

588588

589+
@pytest.mark.filterwarnings(
590+
"ignore:Deprecated in 0.11.0, will be removed in 1.0.0. The property rest.sigv4-enabled is deprecated:DeprecationWarning"
591+
)
589592
def test_list_tables_200_sigv4(rest_mock: Mocker) -> None:
590593
namespace = "examples"
591594
# SigV4 signing replaces the bearer Authorization header with an AWS4-HMAC-SHA256
@@ -610,6 +613,9 @@ def test_list_tables_200_sigv4(rest_mock: Mocker) -> None:
610613
assert rest_mock.called
611614

612615

616+
@pytest.mark.filterwarnings(
617+
"ignore:Deprecated in 0.11.0, will be removed in 1.0.0. The property rest.sigv4-enabled is deprecated:DeprecationWarning"
618+
)
613619
def test_sigv4_adapter_default_retry_config(rest_mock: Mocker) -> None:
614620
catalog = RestCatalog(
615621
"rest",
@@ -628,6 +634,9 @@ def test_sigv4_adapter_default_retry_config(rest_mock: Mocker) -> None:
628634
assert adapter.max_retries.total == SIGV4_MAX_RETRIES_DEFAULT
629635

630636

637+
@pytest.mark.filterwarnings(
638+
"ignore:Deprecated in 0.11.0, will be removed in 1.0.0. The property rest.sigv4-enabled is deprecated:DeprecationWarning"
639+
)
631640
def test_sigv4_adapter_override_retry_config(rest_mock: Mocker) -> None:
632641
catalog = RestCatalog(
633642
"rest",
@@ -804,6 +813,9 @@ def test_list_views_invalid_page_size(rest_mock: Mocker) -> None:
804813
assert str(e.value) == "rest-page-size must be a positive integer"
805814

806815

816+
@pytest.mark.filterwarnings(
817+
"ignore:Deprecated in 0.11.0, will be removed in 1.0.0. The property rest.sigv4-enabled is deprecated:DeprecationWarning"
818+
)
807819
def test_list_views_200_sigv4(rest_mock: Mocker) -> None:
808820
namespace = "examples"
809821
# SigV4 signing replaces the bearer Authorization header with an AWS4-HMAC-SHA256
@@ -2687,6 +2699,9 @@ def test_catalog_close(self, rest_mock: Mocker) -> None:
26872699
# Second close should not raise any exception
26882700
catalog.close()
26892701

2702+
@pytest.mark.filterwarnings(
2703+
"ignore:Deprecated in 0.11.0, will be removed in 1.0.0. The property rest.sigv4-enabled is deprecated:DeprecationWarning"
2704+
)
26902705
def test_rest_catalog_close_sigv4(self, rest_mock: Mocker) -> None:
26912706
catalog = None
26922707
rest_mock.get(
@@ -2729,6 +2744,9 @@ def test_rest_catalog_context_manager_with_exception(self, rest_mock: Mocker) ->
27292744
assert catalog is not None and hasattr(catalog, "_session")
27302745
assert len(catalog._session.adapters) == self.EXPECTED_ADAPTERS
27312746

2747+
@pytest.mark.filterwarnings(
2748+
"ignore:Deprecated in 0.11.0, will be removed in 1.0.0. The property rest.sigv4-enabled is deprecated:DeprecationWarning"
2749+
)
27322750
def test_rest_catalog_context_manager_with_exception_sigv4(self, rest_mock: Mocker) -> None:
27332751
"""Test RestCatalog context manager properly closes with exceptions."""
27342752
catalog = None

tests/catalog/test_rest_auth.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,8 +323,13 @@ def test_sigv4_auth_manager_relocates_delegate_authorization() -> None:
323323
# SigV4 owns Authorization; the delegate's Basic header is relocated.
324324
assert prepared.headers["Authorization"].startswith("AWS4-HMAC-SHA256 Credential=")
325325
assert prepared.headers["Original-Authorization"].startswith("Basic ")
326+
# Relocated header is signed (in SignedHeaders), matching Iceberg Java.
327+
assert "original-authorization" in prepared.headers["Authorization"]
326328

327329

330+
@pytest.mark.filterwarnings(
331+
"ignore:Deprecated in 0.11.0, will be removed in 1.0.0. The property rest.sigv4-enabled is deprecated:DeprecationWarning"
332+
)
328333
def test_sigv4_legacy_config_builds_sigv4_auth_manager(rest_mock: Mocker) -> None:
329334
"""Legacy rest.sigv4-enabled config produces a SigV4AuthManager."""
330335
from pyiceberg.catalog.rest.auth import SigV4AuthManager
@@ -359,6 +364,54 @@ def test_sigv4_auth_type_config_builds_sigv4_auth_manager(rest_mock: Mocker) ->
359364
assert isinstance(catalog._auth_manager, SigV4AuthManager)
360365

361366

367+
def test_sigv4_auth_type_rejects_auth_impl(rest_mock: Mocker) -> None:
368+
"""auth.impl is only valid with auth.type=custom, not sigv4."""
369+
with pytest.raises(ValueError, match="auth.impl can only be specified when using custom auth.type"):
370+
RestCatalog(
371+
"rest",
372+
**{ # type: ignore
373+
"uri": TEST_URI,
374+
"auth": {"type": "sigv4", "impl": "my.custom.AuthManager"},
375+
"rest.signing-region": "us-east-1",
376+
"client.access-key-id": "id",
377+
"client.secret-access-key": "secret",
378+
},
379+
)
380+
381+
382+
def test_sigv4_rejects_sigv4_delegate(rest_mock: Mocker) -> None:
383+
"""A SigV4 delegate cannot itself be sigv4, matching Iceberg Java's AuthManagers check."""
384+
with pytest.raises(ValueError, match="Cannot delegate a SigV4 auth manager to another SigV4 auth manager"):
385+
RestCatalog(
386+
"rest",
387+
**{ # type: ignore
388+
"uri": TEST_URI,
389+
"auth": {"type": "sigv4", "sigv4": {"delegate": {"type": "sigv4"}}},
390+
"rest.signing-region": "us-east-1",
391+
"client.access-key-id": "id",
392+
"client.secret-access-key": "secret",
393+
},
394+
)
395+
396+
397+
def test_sigv4_legacy_flag_emits_deprecation_warning(rest_mock: Mocker) -> None:
398+
"""The legacy rest.sigv4-enabled flag warns and points at auth.type=sigv4, matching Iceberg Java."""
399+
with pytest.warns(DeprecationWarning, match="rest.sigv4-enabled is deprecated"):
400+
RestCatalog(
401+
"rest",
402+
**{
403+
"uri": TEST_URI,
404+
"rest.sigv4-enabled": "true",
405+
"rest.signing-region": "us-east-1",
406+
"client.access-key-id": "id",
407+
"client.secret-access-key": "secret",
408+
},
409+
)
410+
411+
412+
@pytest.mark.filterwarnings(
413+
"ignore:Deprecated in 0.11.0, will be removed in 1.0.0. The property rest.sigv4-enabled is deprecated:DeprecationWarning"
414+
)
362415
def test_sigv4_sign_request_without_body(rest_mock: Mocker) -> None:
363416
from pyiceberg.catalog.rest.auth import EMPTY_BODY_SHA256
364417

@@ -391,6 +444,9 @@ def test_sigv4_sign_request_without_body(rest_mock: Mocker) -> None:
391444
assert "x-amz-content-sha256" in auth_header
392445

393446

447+
@pytest.mark.filterwarnings(
448+
"ignore:Deprecated in 0.11.0, will be removed in 1.0.0. The property rest.sigv4-enabled is deprecated:DeprecationWarning"
449+
)
394450
def test_sigv4_sign_request_with_body(rest_mock: Mocker) -> None:
395451
existing_token = "existing_token"
396452

@@ -429,6 +485,9 @@ def test_sigv4_sign_request_with_body(rest_mock: Mocker) -> None:
429485
assert "x-amz-content-sha256" in auth_header
430486

431487

488+
@pytest.mark.filterwarnings(
489+
"ignore:Deprecated in 0.11.0, will be removed in 1.0.0. The property rest.sigv4-enabled is deprecated:DeprecationWarning"
490+
)
432491
def test_sigv4_content_sha256_with_bytes_body(rest_mock: Mocker) -> None:
433492
existing_token = "existing_token"
434493

@@ -460,6 +519,9 @@ def test_sigv4_content_sha256_with_bytes_body(rest_mock: Mocker) -> None:
460519
assert content_sha256 == expected_sha256
461520

462521

522+
@pytest.mark.filterwarnings(
523+
"ignore:Deprecated in 0.11.0, will be removed in 1.0.0. The property rest.sigv4-enabled is deprecated:DeprecationWarning"
524+
)
463525
def test_sigv4_conflicting_sigv4_headers(rest_mock: Mocker) -> None:
464526
from pyiceberg.catalog.rest.auth import EMPTY_BODY_SHA256
465527

@@ -493,6 +555,9 @@ def test_sigv4_conflicting_sigv4_headers(rest_mock: Mocker) -> None:
493555
assert "X-Amz-Date" in prepared.headers
494556

495557

558+
@pytest.mark.filterwarnings(
559+
"ignore:Deprecated in 0.11.0, will be removed in 1.0.0. The property rest.sigv4-enabled is deprecated:DeprecationWarning"
560+
)
496561
def test_sigv4_canonical_request_uses_hex_payload(rest_mock: Mocker) -> None:
497562
"""Verify that the canonical request uses hex-encoded payload hash, not the base64 header value."""
498563
from typing import Any
@@ -542,6 +607,9 @@ def capturing_add_auth(self: Any, request: Any) -> None:
542607
assert prepared.headers["x-amz-content-sha256"] == base64.b64encode(hashlib.sha256(body_content).digest()).decode()
543608

544609

610+
@pytest.mark.filterwarnings(
611+
"ignore:Deprecated in 0.11.0, will be removed in 1.0.0. The property rest.sigv4-enabled is deprecated:DeprecationWarning"
612+
)
545613
def test_sigv4_content_sha256_matches_iceberg_java_reference(rest_mock: Mocker) -> None:
546614
"""Pin byte-for-byte equivalence with Iceberg Java TestRESTSigV4AuthSession (L121, L177)."""
547615
java_reference_body = b'{"namespace":["ns"],"properties":{}}'
@@ -596,6 +664,9 @@ def test_sigv4_unsupported_body_type_raises() -> None:
596664
manager.sign_request(prepared)
597665

598666

667+
@pytest.mark.filterwarnings(
668+
"ignore:Deprecated in 0.11.0, will be removed in 1.0.0. The property rest.sigv4-enabled is deprecated:DeprecationWarning"
669+
)
599670
def test_sigv4_uses_client_profile_name(rest_mock: Mocker) -> None:
600671
import boto3
601672

0 commit comments

Comments
 (0)