Skip to content

Commit f4ed781

Browse files
SS-369 Refresh catalog-vended storage credentials (#38475)
### Motivation Catalog-vended credentials expire, typically within the hour, and nothing refreshed them. `loadTable` hands their access keys to the FileIO once and OpenDAL keeps signing with them until they stop working, at which point the sink fails and only recovers by restarting the dataflow. Any sink running longer than one credential lifetime hits this. ### Description Adds `VendedCredentialLoader`, a `ProvideCredential` implementation that re-fetches from the catalog's `loadCredentials` endpoint and hands the result to OpenDAL's S3 credential chain. The loader caches with its own deadline rather than relying on reqsign's cache. OpenDAL rebuilds its `Operator` for every file operation, so the `Signer` that holds reqsign's cached credential never survives a single call; without an internal cache this would be one catalog round trip per parquet write and per metadata read. The deadline comes from `s3.session-token-expires-at-ms` where the catalog reports one, refreshing ahead of expiry, and from a short fixed interval where it does not. That interval is a constant for now, with a TODO to make it a dyncfg once we know what real catalogs report. On a 401 or 403 the loader invalidates the catalog token so the next attempt mints a fresh one, since nothing else on the storage path re-mints it. Two supporting changes: - Materialize now constructs the OAuth2 provider itself instead of passing a `credential` catalog property, so a single token object serves both catalog requests and credential refreshes. The two cannot coexist: the catalog client rejects a custom authenticator combined with that property. - `connect` takes the table the handle will be used against, because the credentials endpoint is table-scoped. Callers that only prove reachability (connection validation, sink purification) pass `None`. Installing a loader hands it sole responsibility for S3 credentials, since OpenDAL replaces its entire provider chain and discards the static keys parsed from the vended properties. It is therefore installed only when the connection asked for delegation and a table is known; every other case keeps the existing static-property path. ### Verification `cargo check` and `cargo clippy` are clean. Not yet exercised against a live catalog: the refresh path needs a sink run long enough to cross a credential lifetime, and which branch of the deadline logic applies depends on whether the catalog reports an expiry.
1 parent f40272e commit f4ed781

4 files changed

Lines changed: 611 additions & 24 deletions

File tree

src/sql/src/pure.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -625,8 +625,10 @@ async fn purify_create_sink(
625625
// Now that we've validated the sink's storage creds (if they exist)
626626
// we _could_ use them to build a complete Iceberg client (both catalog and storage).
627627
// TODO(kynan): Actually use those sink-specific creds here instead of ignoring them.
628+
// Purification only proves the catalog is reachable, so it needs no table-scoped
629+
// storage credentials.
628630
let _catalog = connection
629-
.connect(storage_configuration, InTask::No)
631+
.connect(storage_configuration, InTask::No, None)
630632
.await
631633
.map_err(|e| IcebergSinkPurificationError::CatalogError(Arc::new(e)))?;
632634
}

src/storage-types/src/connections.rs

Lines changed: 94 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -24,15 +24,17 @@ use aws_sigv4::sign::v4;
2424
// Aliased to avoid colliding with `mz_ccsr::tls::Identity`.
2525
use aws_smithy_runtime_api::client::identity::Identity as AwsIdentity;
2626
use base64::Engine;
27-
use http::{HeaderName, HeaderValue};
27+
use http::{HeaderMap, HeaderName, HeaderValue};
2828
use iceberg::Catalog;
2929
use iceberg::CatalogBuilder;
30+
use iceberg::TableIdent;
3031
use iceberg::io::{
3132
GCS_CREDENTIALS_JSON, GCS_DISABLE_CONFIG_LOAD, GCS_DISABLE_VM_METADATA, GCS_USER_PROJECT,
3233
S3_ACCESS_KEY_ID, S3_DISABLE_EC2_METADATA, S3_REGION, S3_SECRET_ACCESS_KEY,
3334
};
3435
use iceberg_catalog_rest::{
35-
REST_CATALOG_PROP_URI, REST_CATALOG_PROP_WAREHOUSE, RequestAuthenticator, RestCatalogBuilder,
36+
OAuth2TokenProvider, REST_CATALOG_PROP_URI, REST_CATALOG_PROP_WAREHOUSE, RequestAuthenticator,
37+
RestCatalogBuilder, TokenProvider,
3638
};
3739
use iceberg_storage_opendal::{
3840
AwsCredential, CustomAwsCredentialLoader, OpenDalStorageFactory, ProvideCredential,
@@ -89,13 +91,17 @@ use crate::errors::{ContextCreationError, CsrConnectError};
8991

9092
pub mod aws;
9193
pub mod gcp;
94+
mod iceberg_credentials;
9295
pub mod inline;
9396
pub mod string_or_secret;
9497

95-
const REST_CATALOG_PROP_SCOPE: &str = "scope";
96-
const REST_CATALOG_PROP_CREDENTIAL: &str = "credential";
97-
/// Overrides the OAuth2 token endpoint. Spelled `uri` because that is the property name the
98-
/// Iceberg REST clients agree on, even though the SQL option says `URL`.
98+
/// The OAuth2 form field naming the scopes a token is requested for.
99+
///
100+
/// Materialize drives the OAuth2 exchange itself rather than through the `credential`,
101+
/// `oauth2-server-uri`, and `scope` catalog properties, so that one token object serves both
102+
/// catalog requests and storage-credential refreshes.
103+
const OAUTH2_PARAM_SCOPE: &str = "scope";
104+
99105
const REST_CATALOG_PROP_OAUTH2_SERVER_URI: &str = "oauth2-server-uri";
100106
/// Requests catalog-vended storage credentials. `iceberg-rust` turns `header.*` catalog
101107
/// properties into headers on every REST request, the same way the Iceberg Java client
@@ -793,18 +799,27 @@ impl<C: ConnectionAccess> IcebergCatalogConnection<C> {
793799
}
794800

795801
impl IcebergCatalogConnection<InlinedConnection> {
802+
/// Connects to the catalog.
803+
///
804+
/// `table` names the table this handle will be used against. It is needed only to keep
805+
/// catalog-vended storage credentials refreshed, which the REST specification scopes to a
806+
/// single table. Passing `None` leaves the connection on whatever credentials the catalog
807+
/// supplies at `loadTable` time, which expire.
796808
pub async fn connect(
797809
&self,
798810
storage_configuration: &StorageConfiguration,
799811
in_task: InTask,
812+
table: Option<&TableIdent>,
800813
) -> Result<Arc<dyn Catalog>, anyhow::Error> {
801814
match self.catalog {
802815
IcebergCatalogImpl::S3TablesRest(ref s3tables) => {
816+
// S3 Tables signs every request with SigV4 off a refreshable AWS provider, so it
817+
// has no vended credential to keep alive.
803818
self.connect_s3tables(s3tables, storage_configuration, in_task)
804819
.await
805820
}
806821
IcebergCatalogImpl::Rest(ref rest) => {
807-
self.connect_rest(rest, storage_configuration, in_task)
822+
self.connect_rest(rest, storage_configuration, in_task, table)
808823
.await
809824
}
810825
}
@@ -972,6 +987,7 @@ impl IcebergCatalogConnection<InlinedConnection> {
972987
rest: &RestIcebergCatalog,
973988
storage_configuration: &StorageConfiguration,
974989
in_task: InTask,
990+
table: Option<&TableIdent>,
975991
) -> Result<Arc<dyn Catalog>, anyhow::Error> {
976992
let mut props = BTreeMap::from([(
977993
REST_CATALOG_PROP_URI.to_string(),
@@ -982,6 +998,10 @@ impl IcebergCatalogConnection<InlinedConnection> {
982998
props.insert(REST_CATALOG_PROP_WAREHOUSE.to_string(), warehouse.clone());
983999
}
9841000

1001+
// One client for catalog requests, OAuth token requests, and credential refreshes, so all
1002+
// three share a connection pool. `iceberg-rust` would otherwise default to its own.
1003+
let client = reqwest::Client::new();
1004+
9851005
// Catalog auth is configured through a combination of `props` and `.with_authenticator(...)`,
9861006
// which happen at different stages of the [`RestCatalogBuilder`] -> [`RestCatalog`]
9871007
// construction pipeline.
@@ -998,7 +1018,6 @@ impl IcebergCatalogConnection<InlinedConnection> {
9981018
)
9991019
.await
10001020
.map_err(|e| anyhow!("failed to read Iceberg catalog credential: {e}"))?;
1001-
props.insert(REST_CATALOG_PROP_CREDENTIAL.to_string(), credential);
10021021

10031022
if let Some(server_url) = server_url {
10041023
// The OAuth2 exchange POSTs the catalog credential to this URL, so a URL
@@ -1031,9 +1050,63 @@ impl IcebergCatalogConnection<InlinedConnection> {
10311050
);
10321051
}
10331052

1034-
if let Some(scope) = scope {
1035-
props.insert(REST_CATALOG_PROP_SCOPE.to_string(), scope.clone());
1036-
}
1053+
// Materialize builds an OAuth2 provider shared across both catalog requests
1054+
// and the vended credentials refresh below.
1055+
let token_endpoint = match server_url {
1056+
Some(server_url) => server_url.clone(),
1057+
// Matches `iceberg-rust`'s default when no `oauth2-server-uri` is configured.
1058+
None => format!(
1059+
"{}/v1/oauth/tokens",
1060+
self.uri.as_str().trim_end_matches('/')
1061+
),
1062+
};
1063+
let (client_id, client_secret) = match credential.split_once(':') {
1064+
Some((client_id, client_secret)) => {
1065+
(Some(client_id.to_string()), client_secret.to_string())
1066+
}
1067+
None => (None, credential),
1068+
};
1069+
let oauth_params = BTreeMap::from([(
1070+
OAUTH2_PARAM_SCOPE.to_string(),
1071+
// The default `iceberg-rust` applies when the connection names no scope.
1072+
scope.clone().unwrap_or_else(|| "catalog".to_string()),
1073+
)]);
1074+
let token: Arc<dyn TokenProvider> = Arc::new(OAuth2TokenProvider::new(
1075+
client.clone(),
1076+
client_id,
1077+
client_secret,
1078+
token_endpoint,
1079+
// The token request needs none of the catalog's headers, and
1080+
// `OAuth2TokenProvider` sets the form content type itself.
1081+
HeaderMap::new(),
1082+
oauth_params.into_iter().collect(),
1083+
));
1084+
1085+
// Installing a loader hands it sole responsibility for S3 credentials: OpenDAL
1086+
// replaces its whole provider chain, including the static keys parsed out of the
1087+
// catalog's vended `storage-credentials` props. So only install one when the
1088+
// connection asked for delegation and we know which table to refresh, and let the
1089+
// static props serve every other case.
1090+
let customized_credential_load = match (&rest.access_delegation, table) {
1091+
(Some(IcebergAccessDelegation::VendedCredentials), Some(table)) => {
1092+
let endpoint = iceberg_credentials::table_credentials_endpoint(
1093+
&self.uri,
1094+
&client,
1095+
&token,
1096+
rest.warehouse.as_deref(),
1097+
table,
1098+
)
1099+
.await?;
1100+
Some(CustomAwsCredentialLoader::new(
1101+
iceberg_credentials::VendedCredentialLoader::new(
1102+
client.clone(),
1103+
endpoint,
1104+
Arc::clone(&token),
1105+
),
1106+
))
1107+
}
1108+
_ => None,
1109+
};
10371110

10381111
(
10391112
OpenDalStorageFactory::S3 {
@@ -1043,9 +1116,12 @@ impl IcebergCatalogConnection<InlinedConnection> {
10431116
// vends instead, it returns per-table `storage-credentials` that
10441117
// `iceberg-rust` wires into the same FileIO.
10451118
// N.B. This is not confirmed to work with other catalog & storage implementations.
1046-
customized_credential_load: None,
1119+
customized_credential_load,
10471120
},
1048-
None,
1121+
// NOTE: We construct our own OAuth authenticator for the Catalog client instead of using the one built in.
1122+
// This means we ignore auth overrides from `/v1/config` (e.g. `oauth2-server-uri`).
1123+
// This is okay because users can set these configs from Mz SQL.
1124+
Some(iceberg_catalog_rest::BearerTokenAuthenticator::new(token)),
10491125
)
10501126
}
10511127
IcebergCatalogAuth::Gcp(gcp_connection_reference) => {
@@ -1089,8 +1165,9 @@ impl IcebergCatalogConnection<InlinedConnection> {
10891165
);
10901166
}
10911167

1092-
let mut catalog =
1093-
RestCatalogBuilder::default().with_storage_factory(Arc::new(storage_factory));
1168+
let mut catalog = RestCatalogBuilder::default()
1169+
.with_storage_factory(Arc::new(storage_factory))
1170+
.with_client(client);
10941171
if let Some(auth) = custom_authenticator {
10951172
catalog = catalog.with_authenticator(Arc::new(auth));
10961173
}
@@ -1106,8 +1183,9 @@ impl IcebergCatalogConnection<InlinedConnection> {
11061183
_id: CatalogItemId,
11071184
storage_configuration: &StorageConfiguration,
11081185
) -> Result<(), ConnectionValidationError> {
1186+
// Validation only lists namespaces, so it needs no table-scoped credentials.
11091187
let catalog = self
1110-
.connect(storage_configuration, InTask::No)
1188+
.connect(storage_configuration, InTask::No, None)
11111189
.await
11121190
.map_err(|e| {
11131191
ConnectionValidationError::Other(anyhow!("failed to connect to catalog: {e}"))

0 commit comments

Comments
 (0)