Skip to content

Commit 350dc82

Browse files
committed
feat: implement vended credential refresh
1 parent 2c75523 commit 350dc82

8 files changed

Lines changed: 656 additions & 72 deletions

File tree

pyiceberg/catalog/rest/__init__.py

Lines changed: 31 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,11 @@
3232
from pyiceberg import __version__
3333
from pyiceberg.catalog import BOTOCORE_SESSION, TOKEN, URI, WAREHOUSE_LOCATION, Catalog, PropertiesUpdateSummary
3434
from pyiceberg.catalog.rest.auth import AUTH_MANAGER, AuthManager, AuthManagerAdapter, AuthManagerFactory, LegacyOAuth2AuthManager
35+
from pyiceberg.catalog.rest.credential_provider import (
36+
REFRESH_CREDENTIALS_ENABLED,
37+
CredentialsProvider,
38+
resolve_storage_credentials,
39+
)
3540
from pyiceberg.catalog.rest.response import _handle_non_200_response
3641
from pyiceberg.catalog.rest.scan_planning import (
3742
FetchScanTasksRequest,
@@ -466,26 +471,6 @@ def _create_session(self) -> Session:
466471

467472
return session
468473

469-
@staticmethod
470-
def _resolve_storage_credentials(storage_credentials: list[StorageCredential], location: str | None) -> Properties:
471-
"""Resolve the best-matching storage credential by longest prefix match.
472-
473-
Mirrors the Java implementation in S3FileIO.clientForStoragePath() which iterates
474-
over storage credential prefixes and selects the one with the longest match.
475-
476-
See: https://github.com/apache/iceberg/blob/main/aws/src/main/java/org/apache/iceberg/aws/s3/S3FileIO.java
477-
"""
478-
if not storage_credentials or not location:
479-
return {}
480-
481-
best_match: StorageCredential | None = None
482-
for cred in storage_credentials:
483-
if location.startswith(cred.prefix):
484-
if best_match is None or len(cred.prefix) > len(best_match.prefix):
485-
best_match = cred
486-
487-
return best_match.config if best_match else {}
488-
489474
def _load_file_io(self, properties: Properties = EMPTY_DICT, location: str | None = None) -> FileIO:
490475
merged_properties = {**self.properties, **properties}
491476
if self._auth_manager:
@@ -827,37 +812,50 @@ def add_headers(self, request: PreparedRequest, **kwargs: Any) -> None: # pylin
827812

828813
def _response_to_table(self, identifier_tuple: tuple[str, ...], table_response: TableResponse) -> Table:
829814
# Per Iceberg spec: storage-credentials take precedence over config
830-
credential_config = self._resolve_storage_credentials(
831-
table_response.storage_credentials, table_response.metadata_location
815+
credential_config = resolve_storage_credentials(table_response.storage_credentials, table_response.metadata_location)
816+
io = self._load_file_io(
817+
{**table_response.metadata.properties, **table_response.config, **credential_config},
818+
table_response.metadata_location,
832819
)
820+
self._attach_credentials_provider(io, identifier_tuple, table_response.storage_credentials)
833821
return Table(
834822
identifier=identifier_tuple,
835823
metadata_location=table_response.metadata_location, # type: ignore
836824
metadata=table_response.metadata,
837-
io=self._load_file_io(
838-
{**table_response.metadata.properties, **table_response.config, **credential_config},
839-
table_response.metadata_location,
840-
),
825+
io=io,
841826
catalog=self,
842827
config=table_response.config,
843828
)
844829

845830
def _response_to_staged_table(self, identifier_tuple: tuple[str, ...], table_response: TableResponse) -> StagedTable:
846831
# Per Iceberg spec: storage-credentials take precedence over config
847-
credential_config = self._resolve_storage_credentials(
848-
table_response.storage_credentials, table_response.metadata_location
832+
credential_config = resolve_storage_credentials(table_response.storage_credentials, table_response.metadata_location)
833+
io = self._load_file_io(
834+
{**table_response.metadata.properties, **table_response.config, **credential_config},
835+
table_response.metadata_location,
849836
)
837+
self._attach_credentials_provider(io, identifier_tuple, table_response.storage_credentials)
850838
return StagedTable(
851839
identifier=identifier_tuple,
852840
metadata_location=table_response.metadata_location, # type: ignore
853841
metadata=table_response.metadata,
854-
io=self._load_file_io(
855-
{**table_response.metadata.properties, **table_response.config, **credential_config},
856-
table_response.metadata_location,
857-
),
842+
io=io,
858843
catalog=self,
859844
)
860845

846+
def _attach_credentials_provider(
847+
self, io: FileIO, identifier: str | Identifier, storage_credentials: list[StorageCredential]
848+
) -> None:
849+
"""Attach a CredentialsProvider to io if credential refresh is enabled and credentials were vended.
850+
851+
The refresh callback returns the full LoadCredentialsResponse so the provider can re-run
852+
longest-prefix matching against the freshly vended credentials.
853+
"""
854+
if storage_credentials and property_as_bool(self.properties, REFRESH_CREDENTIALS_ENABLED, False):
855+
io.set_credentials_provider(
856+
CredentialsProvider(storage_credentials, refresh_fn=lambda: self._load_credentials(identifier))
857+
)
858+
861859
def _response_to_view(self, identifier_tuple: tuple[str, ...], view_response: ViewResponse) -> View:
862860
return View(
863861
identifier=identifier_tuple,
@@ -1124,7 +1122,7 @@ def load_credentials(
11241122
) -> Properties:
11251123
"""Load vended storage credentials and return the best match for a location."""
11261124
credentials_response = self._load_credentials(identifier)
1127-
return self._resolve_storage_credentials(credentials_response.storage_credentials, location)
1125+
return resolve_storage_credentials(credentials_response.storage_credentials, location)
11281126

11291127
@retry(**_RETRY_ARGS)
11301128
@override
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
# Licensed to the Apache Software Foundation (ASF) under one
2+
# or more contributor license agreements. See the NOTICE file
3+
# distributed with this work for additional information
4+
# regarding copyright ownership. The ASF licenses this file
5+
# to you under the Apache License, Version 2.0 (the
6+
# "License"); you may not use this file except in compliance
7+
# with the License. You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing,
12+
# software distributed under the License is distributed on an
13+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
# KIND, either express or implied. See the License for the
15+
# specific language governing permissions and limitations
16+
# under the License.
17+
from __future__ import annotations
18+
19+
import threading
20+
from collections.abc import Callable
21+
from datetime import datetime
22+
from typing import TYPE_CHECKING
23+
from urllib.parse import urlparse
24+
25+
from pyiceberg.catalog.rest.scan_planning import StorageCredential
26+
from pyiceberg.io import S3_SESSION_TOKEN_EXPIRES_AT_MS
27+
from pyiceberg.typedef import Properties
28+
from pyiceberg.utils.properties import get_first_property_value
29+
30+
if TYPE_CHECKING:
31+
from pyiceberg.catalog.rest import LoadCredentialsResponse
32+
33+
REFRESH_CREDENTIALS_ENABLED = "client.refresh-credentials-enabled"
34+
35+
36+
def is_s3_credential_expired(config: Properties, threshold_seconds: int = 300) -> bool:
37+
"""Return True if the S3 session token expires within threshold_seconds (5 mins)."""
38+
if expiry := get_first_property_value(config, S3_SESSION_TOKEN_EXPIRES_AT_MS):
39+
expires_at = datetime.fromtimestamp(int(expiry) / 1000)
40+
seconds_remaining = (expires_at - datetime.now()).total_seconds()
41+
return seconds_remaining < threshold_seconds
42+
return False
43+
44+
45+
# Per-scheme hooks for detecting whether a resolved credential needs to be refreshed.
46+
# Other schemes (e.g. gs, abfss) can register here later.
47+
NEEDS_REFRESH_BY_SCHEME: dict[str, Callable[[Properties], bool]] = {
48+
"s3": is_s3_credential_expired,
49+
"s3a": is_s3_credential_expired,
50+
"s3n": is_s3_credential_expired,
51+
}
52+
53+
54+
def resolve_storage_credentials(storage_credentials: list[StorageCredential], location: str | None) -> Properties:
55+
"""Resolve the best-matching storage credential by longest prefix match.
56+
57+
Mirrors the Java implementation in S3FileIO.clientForStoragePath() which iterates
58+
over storage credential prefixes and selects the one with the longest match.
59+
60+
See: https://github.com/apache/iceberg/blob/main/aws/src/main/java/org/apache/iceberg/aws/s3/S3FileIO.java
61+
"""
62+
if not storage_credentials or not location:
63+
return {}
64+
65+
best_match: StorageCredential | None = None
66+
for cred in storage_credentials:
67+
if location.startswith(cred.prefix):
68+
if best_match is None or len(cred.prefix) > len(best_match.prefix):
69+
best_match = cred
70+
71+
return best_match.config if best_match else {}
72+
73+
74+
class CredentialsProvider:
75+
"""Vended-credential refresh and location-based lookup for a REST catalog table."""
76+
77+
_storage_credentials: list[StorageCredential]
78+
_refresh_fn: Callable[[], LoadCredentialsResponse]
79+
_needs_refresh_by_scheme: dict[str, Callable[[Properties], bool]]
80+
_lock: threading.Lock
81+
82+
def __init__(
83+
self,
84+
storage_credentials: list[StorageCredential],
85+
refresh_fn: Callable[[], LoadCredentialsResponse],
86+
needs_refresh_by_scheme: dict[str, Callable[[Properties], bool]] | None = None,
87+
):
88+
self._storage_credentials = storage_credentials
89+
self._refresh_fn = refresh_fn
90+
self._needs_refresh_by_scheme = (
91+
needs_refresh_by_scheme if needs_refresh_by_scheme is not None else NEEDS_REFRESH_BY_SCHEME
92+
)
93+
self._lock = threading.Lock()
94+
95+
def _can_refresh(self, location: str) -> bool:
96+
scheme = urlparse(location).scheme
97+
refresh_by_scheme = self._needs_refresh_by_scheme.get(scheme)
98+
config = resolve_storage_credentials(self._storage_credentials, location)
99+
return config != {} and refresh_by_scheme is not None and refresh_by_scheme(config)
100+
101+
def properties_for(self, location: str) -> Properties:
102+
"""Return the credential properties that apply to the given location, refreshing if needed."""
103+
config = resolve_storage_credentials(self._storage_credentials, location)
104+
105+
if self._can_refresh(location):
106+
with self._lock:
107+
if self._can_refresh(location):
108+
response = self._refresh_fn()
109+
self._storage_credentials = response.storage_credentials
110+
config = resolve_storage_credentials(self._storage_credentials, location)
111+
112+
return config

pyiceberg/io/__init__.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,16 @@
4141

4242
logger = logging.getLogger(__name__)
4343

44+
45+
@runtime_checkable
46+
class CredentialsProviderProtocol(Protocol):
47+
"""Protocol for objects that can resolve credential properties for a file location."""
48+
49+
def properties_for(self, location: str) -> Properties:
50+
"""Return the credential properties that apply to the given location."""
51+
...
52+
53+
4454
AWS_PROFILE_NAME = "client.profile-name"
4555
AWS_REGION = "client.region"
4656
AWS_ACCESS_KEY_ID = "client.access-key-id"
@@ -54,6 +64,7 @@
5464
S3_ACCESS_KEY_ID = "s3.access-key-id"
5565
S3_SECRET_ACCESS_KEY = "s3.secret-access-key"
5666
S3_SESSION_TOKEN = "s3.session-token"
67+
S3_SESSION_TOKEN_EXPIRES_AT_MS = "s3.session-token-expires-at-ms"
5768
S3_REGION = "s3.region"
5869
S3_RESOLVE_REGION = "s3.resolve-region"
5970
S3_PROXY_URI = "s3.proxy-uri"
@@ -258,6 +269,7 @@ class FileIO(ABC):
258269
"""A base class for FileIO implementations."""
259270

260271
properties: Properties
272+
_credentials_provider: CredentialsProviderProtocol | None = None
261273

262274
def __init__(self, properties: Properties = EMPTY_DICT):
263275
self.properties = properties
@@ -291,6 +303,18 @@ def delete(self, location: str | InputFile | OutputFile) -> None:
291303
FileNotFoundError: When the file at the provided location does not exist.
292304
"""
293305

306+
def set_credentials_provider(self, provider: CredentialsProviderProtocol) -> None:
307+
"""Inject a credentials provider for refreshing vended storage credentials.
308+
309+
Backends that support credential refresh (e.g. S3) consult the provider at file-access
310+
time and rebuild their underlying filesystem when credentials change. Backends that do
311+
not support refresh simply hold the reference without using it.
312+
313+
Args:
314+
provider (CredentialsProviderProtocol): Resolves credential properties for a file location.
315+
"""
316+
self._credentials_provider = provider
317+
294318

295319
LOCATION = "location"
296320
WAREHOUSE = "warehouse"

pyiceberg/io/fsspec.py

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -443,7 +443,7 @@ def new_input(self, location: str) -> FsspecInputFile:
443443
FsspecInputFile: An FsspecInputFile instance for the given location.
444444
"""
445445
uri = urlparse(location)
446-
fs = self._get_fs_from_uri(uri)
446+
fs = self._get_fs_from_uri(uri, location)
447447
return FsspecInputFile(location=location, fs=fs)
448448

449449
@override
@@ -457,7 +457,7 @@ def new_output(self, location: str) -> FsspecOutputFile:
457457
FsspecOutputFile: An FsspecOutputFile instance for the given location.
458458
"""
459459
uri = urlparse(location)
460-
fs = self._get_fs_from_uri(uri)
460+
fs = self._get_fs_from_uri(uri, location)
461461
return FsspecOutputFile(location=location, fs=fs)
462462

463463
@override
@@ -475,31 +475,44 @@ def delete(self, location: str | InputFile | OutputFile) -> None:
475475
str_location = location
476476

477477
uri = urlparse(str_location)
478-
fs = self._get_fs_from_uri(uri)
478+
fs = self._get_fs_from_uri(uri, str_location)
479479
fs.rm(str_location)
480480

481-
def _get_fs_from_uri(self, uri: "ParseResult") -> AbstractFileSystem:
482-
"""Get a filesystem from a parsed URI, using hostname for ADLS account resolution."""
481+
def _get_fs_from_uri(self, uri: "ParseResult", location: str) -> AbstractFileSystem:
482+
"""Get a filesystem from a parsed URI, using hostname for ADLS account resolution.
483+
484+
When a credentials provider is attached, its resolved (and possibly refreshed) credential
485+
properties are folded into the cache key so rotated credentials build a new filesystem.
486+
"""
487+
creds_key: frozenset[tuple[str, str]] = frozenset()
488+
if provider := self._credentials_provider:
489+
creds_key = frozenset(provider.properties_for(location).items())
483490
if uri.scheme in _ADLS_SCHEMES:
484-
return self.get_fs(uri.scheme, uri.hostname)
485-
return self.get_fs(uri.scheme)
491+
return self.get_fs(uri.scheme, uri.hostname, creds_key)
492+
return self.get_fs(uri.scheme, None, creds_key)
486493

487-
def get_fs(self, scheme: str, hostname: str | None = None) -> AbstractFileSystem:
494+
def get_fs(
495+
self, scheme: str, hostname: str | None = None, creds_key: frozenset[tuple[str, str]] = frozenset()
496+
) -> AbstractFileSystem:
488497
"""Get a filesystem for a specific scheme, cached per thread."""
489498
if not hasattr(self._thread_locals, "get_fs_cached"):
490499
self._thread_locals.get_fs_cached = lru_cache(self._get_fs)
491500

492-
return self._thread_locals.get_fs_cached(scheme, hostname)
501+
return self._thread_locals.get_fs_cached(scheme, hostname, creds_key)
493502

494-
def _get_fs(self, scheme: str, hostname: str | None = None) -> AbstractFileSystem:
503+
def _get_fs(
504+
self, scheme: str, hostname: str | None = None, creds_key: frozenset[tuple[str, str]] = frozenset()
505+
) -> AbstractFileSystem:
495506
"""Get a filesystem for a specific scheme."""
496507
if scheme not in self._scheme_to_fs:
497508
raise ValueError(f"No registered filesystem for scheme: {scheme}")
498509

510+
properties = {**self.properties, **dict(creds_key)}
511+
499512
if scheme in _ADLS_SCHEMES:
500-
return _adls(self.properties, hostname)
513+
return _adls(properties, hostname)
501514

502-
return self._scheme_to_fs[scheme](self.properties)
515+
return self._scheme_to_fs[scheme](properties)
503516

504517
def __getstate__(self) -> dict[str, Any]:
505518
"""Create a dictionary of the FsSpecFileIO fields used when pickling."""

0 commit comments

Comments
 (0)