diff --git a/mkdocs/docs/configuration.md b/mkdocs/docs/configuration.md index f4fbe0c8d8..1c9e327fd7 100644 --- a/mkdocs/docs/configuration.md +++ b/mkdocs/docs/configuration.md @@ -359,7 +359,9 @@ catalog: #### Authentication Options -##### OAuth2 +##### Legacy OAuth2 + +Legacy OAuth2 Properties will be removed in PyIceberg 1.0 in place of pluggable AuthManager properties below | Key | Example | Description | | ------------------- | -------------------------------- | -------------------------------------------------------------------------------------------------- | @@ -378,6 +380,77 @@ catalog: | rest.signing-region | us-east-1 | The region to use when SigV4 signing a request | | rest.signing-name | execute-api | The service signing name to use when SigV4 signing a request | +##### Pluggable Authentication via AuthManager + +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. + +###### Supported Authentication Types + +- `noop`: No authentication (no Authorization header sent). +- `basic`: HTTP Basic authentication. +- `custom`: Custom authentication manager (requires `auth.impl`). + +###### Configuration Properties + +The `auth` block is structured as follows: + +```yaml +catalog: + default: + type: rest + uri: http://rest-catalog/ws/ + auth: + type: + : + # Type-specific configuration + impl: # Only for custom auth +``` + +###### Property Reference + +| Property | Required | Description | +|------------------|----------|-------------------------------------------------------------------------------------------------| +| `auth.type` | Yes | The authentication type to use (`noop`, `basic`, or `custom`). | +| `auth.impl` | Conditionally | The fully qualified class path for a custom AuthManager. Required if `auth.type` is `custom`. | +| `auth.basic` | If type is `basic` | Block containing `username` and `password` for HTTP Basic authentication. | +| `auth.custom` | If type is `custom` | Block containing configuration for the custom AuthManager. | + +###### Examples + +No Authentication: + +```yaml +auth: + type: noop +``` + +Basic Authentication: + +```yaml +auth: + type: basic + basic: + username: myuser + password: mypass +``` + +Custom Authentication: + +```yaml +auth: + type: custom + impl: mypackage.module.MyAuthManager + custom: + property1: value1 + property2: value2 +``` + +###### Notes + +- If `auth.type` is `custom`, you **must** specify `auth.impl` with the full class path to your custom AuthManager. +- If `auth.type` is not `custom`, specifying `auth.impl` is not allowed. +- The configuration block under each type (e.g., `basic`, `custom`) is passed as keyword arguments to the corresponding AuthManager. + #### Common Integrations & Examples diff --git a/pyiceberg/catalog/rest/__init__.py b/pyiceberg/catalog/rest/__init__.py index 0972d7792f..b39af9fc92 100644 --- a/pyiceberg/catalog/rest/__init__.py +++ b/pyiceberg/catalog/rest/__init__.py @@ -134,6 +134,8 @@ class IdentifierKind(Enum): SIGV4_SERVICE = "rest.signing-name" OAUTH2_SERVER_URI = "oauth2-server-uri" SNAPSHOT_LOADING_MODE = "snapshot-loading-mode" +AUTH = "auth" +CUSTOM = "custom" NAMESPACE_SEPARATOR = b"\x1f".decode(UTF8) @@ -247,7 +249,23 @@ def _create_session(self) -> Session: elif ssl_client_cert := ssl_client.get(CERT): session.cert = ssl_client_cert - session.auth = AuthManagerAdapter(self._create_legacy_oauth2_auth_manager(session)) + if auth_config := self.properties.get(AUTH): + auth_type = auth_config.get("type") + if auth_type is None: + raise ValueError("auth.type must be defined") + auth_type_config = auth_config.get(auth_type, {}) + auth_impl = auth_config.get("impl") + + if auth_type == CUSTOM and not auth_impl: + raise ValueError("auth.impl must be specified when using custom auth.type") + + if auth_type != CUSTOM and auth_impl: + raise ValueError("auth.impl can only be specified when using custom auth.type") + + session.auth = AuthManagerAdapter(AuthManagerFactory.create(auth_impl or auth_type, auth_type_config)) + else: + session.auth = AuthManagerAdapter(self._create_legacy_oauth2_auth_manager(session)) + # Set HTTP headers self._config_headers(session) diff --git a/pyiceberg/catalog/rest/auth.py b/pyiceberg/catalog/rest/auth.py index 89395f1158..c0a0267f54 100644 --- a/pyiceberg/catalog/rest/auth.py +++ b/pyiceberg/catalog/rest/auth.py @@ -42,11 +42,15 @@ def auth_header(self) -> Optional[str]: class NoopAuthManager(AuthManager): + """Auth Manager implementation with no auth.""" + def auth_header(self) -> Optional[str]: return None class BasicAuthManager(AuthManager): + """AuthManager implementation that supports basic password auth.""" + def __init__(self, username: str, password: str): credentials = f"{username}:{password}" self._token = base64.b64encode(credentials.encode()).decode() @@ -56,6 +60,12 @@ def auth_header(self) -> str: class LegacyOAuth2AuthManager(AuthManager): + """Legacy OAuth2 AuthManager implementation. + + This class exists for backward compatibility, and will be removed in + PyIceberg 1.0.0 in favor of OAuth2AuthManager. + """ + _session: Session _auth_url: Optional[str] _token: Optional[str] diff --git a/tests/catalog/test_rest.py b/tests/catalog/test_rest.py index ed91dd15a1..f60231ac30 100644 --- a/tests/catalog/test_rest.py +++ b/tests/catalog/test_rest.py @@ -15,6 +15,7 @@ # specific language governing permissions and limitations # under the License. # pylint: disable=redefined-outer-name,unused-argument +import base64 import os from typing import Any, Callable, Dict, cast from unittest import mock @@ -1519,6 +1520,132 @@ def test_request_session_with_ssl_client_cert() -> None: assert "Could not find the TLS certificate file, invalid path: path_to_client_cert" in str(e.value) +def test_rest_catalog_with_basic_auth_type(rest_mock: Mocker) -> None: + # Given + rest_mock.get( + f"{TEST_URI}v1/config", + json={"defaults": {}, "overrides": {}}, + status_code=200, + ) + # Given + catalog_properties = { + "uri": TEST_URI, + "auth": { + "type": "basic", + "basic": { + "username": "one", + "password": "two", + }, + }, + } + catalog = RestCatalog("rest", **catalog_properties) # type: ignore + assert catalog.uri == TEST_URI + + encoded_user_pass = base64.b64encode(b"one:two").decode() + expected_auth_header = f"Basic {encoded_user_pass}" + assert rest_mock.last_request.headers["Authorization"] == expected_auth_header + + +def test_rest_catalog_with_custom_auth_type() -> None: + # Given + catalog_properties = { + "uri": TEST_URI, + "auth": { + "type": "custom", + "impl": "dummy.nonexistent.package", + "custom": { + "property1": "one", + "property2": "two", + }, + }, + } + with pytest.raises(ValueError) as e: + # Missing namespace + RestCatalog("rest", **catalog_properties) # type: ignore + assert "Could not load AuthManager class for 'dummy.nonexistent.package'" in str(e.value) + + +def test_rest_catalog_with_custom_basic_auth_type(rest_mock: Mocker) -> None: + # Given + catalog_properties = { + "uri": TEST_URI, + "auth": { + "type": "custom", + "impl": "pyiceberg.catalog.rest.auth.BasicAuthManager", + "custom": { + "username": "one", + "password": "two", + }, + }, + } + rest_mock.get( + f"{TEST_URI}v1/config", + json={"defaults": {}, "overrides": {}}, + status_code=200, + ) + catalog = RestCatalog("rest", **catalog_properties) # type: ignore + assert catalog.uri == TEST_URI + + encoded_user_pass = base64.b64encode(b"one:two").decode() + expected_auth_header = f"Basic {encoded_user_pass}" + assert rest_mock.last_request.headers["Authorization"] == expected_auth_header + + +def test_rest_catalog_with_custom_auth_type_no_impl() -> None: + # Given + catalog_properties = { + "uri": TEST_URI, + "auth": { + "type": "custom", + "custom": { + "property1": "one", + "property2": "two", + }, + }, + } + with pytest.raises(ValueError) as e: + # Missing namespace + RestCatalog("rest", **catalog_properties) # type: ignore + assert "auth.impl must be specified when using custom auth.type" in str(e.value) + + +def test_rest_catalog_with_non_custom_auth_type_impl() -> None: + # Given + catalog_properties = { + "uri": TEST_URI, + "auth": { + "type": "basic", + "impl": "basic.package", + "basic": { + "username": "one", + "password": "two", + }, + }, + } + with pytest.raises(ValueError) as e: + # Missing namespace + RestCatalog("rest", **catalog_properties) # type: ignore + assert "auth.impl can only be specified when using custom auth.type" in str(e.value) + + +def test_rest_catalog_with_unsupported_auth_type() -> None: + # Given + catalog_properties = { + "uri": TEST_URI, + "auth": { + "type": "unsupported", + "unsupported": { + "property1": "one", + "property2": "two", + }, + }, + } + with pytest.raises(ValueError) as e: + # Missing namespace + RestCatalog("rest", **catalog_properties) # type: ignore + assert "Could not load AuthManager class for 'unsupported'" in str(e.value) + + EXAMPLE_ENV = {"PYICEBERG_CATALOG__PRODUCTION__URI": TEST_URI}