Skip to content

Commit 16e68dc

Browse files
Align SigV4 auth with Java: sign relocated Authorization, validate config, add docs
1 parent e925586 commit 16e68dc

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
@@ -400,6 +400,8 @@ Legacy OAuth2 Properties will be removed in PyIceberg 1.0 in place of pluggable
400400
| rest.signing-region | us-east-1 | The region to use when SigV4 signing a request |
401401
| rest.signing-name | execute-api | The service signing name to use when SigV4 signing a request |
402402

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

405407
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.
@@ -412,6 +414,7 @@ The RESTCatalog supports pluggable authentication via the `auth` configuration b
412414
- `custom`: Custom authentication manager (requires `auth.impl`).
413415
- `google`: Google Authentication support
414416
- `entra`: Microsoft Entra ID (Azure AD) authentication support
417+
- `sigv4`: AWS SigV4 request signing, optionally wrapping a delegate auth type.
415418

416419
###### Configuration Properties
417420

@@ -440,6 +443,7 @@ catalog:
440443
| `auth.custom` | If type is `custom` | Block containing configuration for the custom AuthManager. |
441444
| `auth.google` | If type is `google` | Block containing `credentials_path` to a service account file (if using). Will default to using Application Default Credentials. |
442445
| `auth.entra` | If type is `entra` | Block containing Entra ID configuration. Will default to using DefaultAzureCredential. |
446+
| `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. |
443447

444448
###### Examples
445449

@@ -485,6 +489,24 @@ auth:
485489
property2: value2
486490
```
487491

492+
SigV4 Signing (wrapping OAuth2):
493+
494+
```yaml
495+
auth:
496+
type: sigv4
497+
sigv4:
498+
delegate:
499+
type: oauth2
500+
oauth2:
501+
client_id: my-client-id
502+
client_secret: my-client-secret
503+
token_url: https://auth.example.com/oauth/token
504+
rest.signing-region: us-east-1
505+
rest.signing-name: execute-api
506+
client.access-key-id: my-access-key
507+
client.secret-access-key: my-secret-key
508+
```
509+
488510
###### Notes
489511

490512
- 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
@@ -472,6 +472,12 @@ def _build_auth_manager(self, session: Session) -> AuthManager:
472472
"""Build the AuthManager, wrapping the delegate in SigV4 when enabled."""
473473
delegate = self._build_delegate_auth_manager(session)
474474
if self._is_sigv4_enabled():
475+
if property_as_bool(self.properties, SIGV4, False):
476+
deprecation_message(
477+
deprecated_in="0.11.0",
478+
removed_in="1.0.0",
479+
help_message=f"The property {SIGV4} is deprecated. Please use auth.type={SIGV4_AUTH_TYPE} instead",
480+
)
475481
return self._build_sigv4_auth_manager(delegate)
476482
return delegate
477483

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

485491
if auth_type == SIGV4_AUTH_TYPE:
492+
if auth_config.get("impl"):
493+
raise ValueError("auth.impl can only be specified when using custom auth.type")
486494
# The delegate is configured under auth.sigv4.delegate.*
487495
sigv4_config = auth_config.get(SIGV4_AUTH_TYPE, {})
488496
delegate_config = sigv4_config.get("delegate")
489497
if not delegate_config or "type" not in delegate_config:
490498
# No delegate configured: SigV4-only auth, with no header-based delegate.
491499
return NoopAuthManager()
492500
delegate_type = delegate_config["type"]
501+
if delegate_type == SIGV4_AUTH_TYPE:
502+
raise ValueError("Cannot delegate a SigV4 auth manager to another SigV4 auth manager")
493503
return AuthManagerFactory.create(delegate_type, delegate_config.get(delegate_type, {}))
494504

495505
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
@@ -587,6 +587,9 @@ def test_list_tables_page_size(rest_mock: Mocker) -> None:
587587
]
588588

589589

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

613616

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

631637

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

807816

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

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

2748+
@pytest.mark.filterwarnings(
2749+
"ignore:Deprecated in 0.11.0, will be removed in 1.0.0. The property rest.sigv4-enabled is deprecated:DeprecationWarning"
2750+
)
27332751
def test_rest_catalog_context_manager_with_exception_sigv4(self, rest_mock: Mocker) -> None:
27342752
"""Test RestCatalog context manager properly closes with exceptions."""
27352753
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)