Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
224 changes: 207 additions & 17 deletions crates/catalog/rest/src/catalog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ use crate::response::HttpResponse;
use crate::types::{
CatalogConfig, CommitTableRequest, CommitTableResponse, CreateNamespaceRequest,
CreateTableRequest, ListNamespaceResponse, ListTablesResponse, LoadTableResult,
NamespaceResponse, RegisterTableRequest, RenameTableRequest,
NamespaceResponse, RegisterTableRequest, RenameTableRequest, StorageCredential,
};

/// REST catalog URI
Expand Down Expand Up @@ -847,6 +847,7 @@ impl RestSessionCatalog {
&self,
metadata_location: Option<&str>,
extra_config: Option<HashMap<String, String>>,
storage_credentials: Option<&[StorageCredential]>,
) -> Result<FileIO> {
let mut props = self.client().await?.config.props.clone();
if let Some(config) = extra_config {
Expand Down Expand Up @@ -879,9 +880,20 @@ impl RestSessionCatalog {
)
})?;

let file_io = FileIOBuilder::new(factory).with_props(props).build();
let mut builder = FileIOBuilder::new(factory).with_props(props.clone());

Ok(file_io)
// Vended credentials are scoped per location prefix: give each its own
// storage. Paths under no vended prefix fall back to the default `props`
// above, which carry no credentials.
if let Some(creds) = storage_credentials {
for cred in creds {
let mut prefixed = props.clone();
prefixed.extend(cred.config.clone());
builder = builder.with_prefixed_props(cred.prefix.clone(), prefixed);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

qq: do we plan to handle credential refresh as follow up?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@huan233usc Good question!— not in this PR. FileIO is built once from the LoadTable creds, so no refresh yet. Good follow-up: the opendal S3 backend already has a ProvideCredential hook (reqsign re-invokes on expires_in), so a provider re-fetching from loadCredentials before s3.session-token-expires-at-ms — like Java's VendedCredentialsProvider — fits there. Will track as a separate PR.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hey @huan233usc @plusplusjiajia, cred refresh is pretty important for a workflow I'm working on, so I've created a PR for it here: #2932

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@zakariya-s Great to see this — credential refresh is the natural next step on top of the vended credentials here.

}
}

Ok(builder.build())
}
}

Expand Down Expand Up @@ -1181,14 +1193,15 @@ impl SessionCatalog for RestSessionCatalog {
"Metadata location missing in `create_table` response!",
))?;

let config = response
.config
.into_iter()
.chain(self.user_config.props.clone())
.collect();
let mut base_config = response.config.clone();
base_config.extend(self.user_config.props.clone());

let file_io = self
.load_file_io(Some(metadata_location), Some(config))
.load_file_io(
Some(metadata_location),
Some(base_config),
response.storage_credentials.as_deref(),
)
.await?;

let mut table_builder = Table::builder()
Expand Down Expand Up @@ -1219,6 +1232,9 @@ impl SessionCatalog for RestSessionCatalog {
) -> Result<Table> {
let client = self.client().await?;

// Vended credentials are opt-in via a `header.X-Iceberg-Access-Delegation`
// catalog property (applied to every request like the Iceberg Java client);
// any returned `storage_credentials` are wired into the FileIO below.
let request = HttpRequest::build(
client
.http_client
Expand All @@ -1245,14 +1261,15 @@ impl SessionCatalog for RestSessionCatalog {
}
};

let config = response
.config
.into_iter()
.chain(self.user_config.props.clone())
.collect();
let mut base_config = response.config.clone();
base_config.extend(self.user_config.props.clone());

let file_io = self
.load_file_io(response.metadata_location.as_deref(), Some(config))
.load_file_io(
response.metadata_location.as_deref(),
Some(base_config),
response.storage_credentials.as_deref(),
)
.await?;

let mut table_builder = Table::builder()
Expand Down Expand Up @@ -1391,7 +1408,16 @@ impl SessionCatalog for RestSessionCatalog {
"Metadata location missing in `register_table` response!",
))?;

let file_io = self.load_file_io(Some(metadata_location), None).await?;
let mut base_config = response.config.clone();
base_config.extend(self.user_config.props.clone());

let file_io = self
.load_file_io(
Some(metadata_location),
Some(base_config),
response.storage_credentials.as_deref(),
)
.await?;

let mut table_builder = Table::builder()
.identifier(table_ident.clone())
Expand Down Expand Up @@ -1469,8 +1495,12 @@ impl SessionCatalog for RestSessionCatalog {
}
};

// The commit response carries no credentials, so this FileIO has only the
// catalog-level config. `Transaction::do_commit` swaps in the credentialed
// one from its pre-commit load, and it is the only caller there can be:
// `TableCommit` is buildable inside the `iceberg` crate alone.
let file_io = self
.load_file_io(Some(&response.metadata_location), None)
.load_file_io(Some(&response.metadata_location), None, None)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wonder what the risks are here of not creating FileIo here without the credentials? Is that idea that after we have credential refresh we will just automatically refresh on the next read / write?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@xanderbailey Good question. No risk on the Transaction::commit path: do_commit loads before committing and swaps that load's credentialed FileIO back in afterwards (with_file_io), reloading on SetLocation. Refresh isn't needed here. Only a direct Catalog::update_table caller sees the plain FileIO, and the commit response has no credentials to give it by spec — I've pointed the comment at do_commit.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess in Java they have TableOperations which carries the FileIo rather than having to build a new one after the update 🤔 . There are advantages to the way our commit path works so not saying we should look to move in that direction. Just trying to work out how we could make this more bullet-proof.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@xanderbailey Good comparison — I think we're actually closer to it than it looks. UpdateTableRequest in Java doesn't carry a FileIO either; it stays on TableOperations and commit() leaves it alone. do_commit keeping the table's FileIO across update_table is the same idea, just spelled out because we build a fresh Table rather than mutating ops in place.

On bullet-proofing: TableCommit::build() is pub(crate), so do_commit ends up being the only caller update_table can have. I've added a note to the comment so that's easier to see

.await?;

let mut table_builder = Table::builder()
Expand Down Expand Up @@ -3794,6 +3824,114 @@ mod tests {
rename_table_mock.assert_async().await;
}

#[tokio::test]
async fn test_load_table_uses_vended_credentials() {
let mut server = Server::new_async().await;

let config_mock = create_config_mock(&mut server).await;

// Vended credentials are opt-in via a `header.*` catalog property (like the
// Java client). With it configured, the header is sent and the response's
// `storage-credentials` are accepted (the FileIO builds).
let load_table_mock = server
.mock("GET", "/v1/namespaces/ns1/tables/test1")
.match_header("x-iceberg-access-delegation", "vended-credentials")
.with_status(200)
.with_body_from_file(format!(
"{}/testdata/{}",
env!("CARGO_MANIFEST_DIR"),
"load_table_response_with_credentials.json"
))
.create_async()
.await;

let props = HashMap::from([(
"header.X-Iceberg-Access-Delegation".to_string(),
"vended-credentials".to_string(),
)]);
let catalog = RestCatalog::new(
SessionContext::empty(),
RestCatalogConfig::builder()
.uri(server.url())
.props(props)
.build(),
None,
Some(Arc::new(LocalFsStorageFactory)),
Runtime::current(),
None,
);

let table = catalog
.load_table(&TableIdent::from_strs(["ns1", "test1"]).unwrap())
.await
.unwrap();

assert_eq!(
"s3://warehouse/database/table/metadata/00001-5f2f8166-244c-4eae-ac36-384ecdec81fc.gz.metadata.json",
table.metadata_location().unwrap()
);

// The point of the feature: the vended credentials reach the FileIO,
// scoped to the prefix the server sent them for.
let file_io = table.file_io();
let vended = file_io.config_for("s3://warehouse/database/table/data/f.parquet");
assert_eq!(
vended.get("s3.access-key-id"),
Some(&"vended-key-id".to_string())
);
assert_eq!(
vended.get("s3.session-token"),
Some(&"vended-token".to_string())
);
// A path outside the prefix keeps the credential-free default.
assert_eq!(
file_io
.config_for("s3://warehouse/other/f.parquet")
.get("s3.access-key-id"),
None
);

config_mock.assert_async().await;
load_table_mock.assert_async().await;
}

#[tokio::test]
async fn test_load_table_omits_delegation_header_by_default() {
let mut server = Server::new_async().await;

let config_mock = create_config_mock(&mut server).await;

// No delegation header is hardcoded: without a `header.*` prop, none is sent.
let load_table_mock = server
.mock("GET", "/v1/namespaces/ns1/tables/test1")
.match_header("x-iceberg-access-delegation", mockito::Matcher::Missing)
.with_status(200)
.with_body_from_file(format!(
"{}/testdata/{}",
env!("CARGO_MANIFEST_DIR"),
"load_table_response.json"
))
.create_async()
.await;

let catalog = RestCatalog::new(
SessionContext::empty(),
RestCatalogConfig::builder().uri(server.url()).build(),
None,
Some(Arc::new(LocalFsStorageFactory)),
Runtime::current(),
None,
);

catalog
.load_table(&TableIdent::from_strs(["ns1", "test1"]).unwrap())
.await
.unwrap();

config_mock.assert_async().await;
load_table_mock.assert_async().await;
}

#[tokio::test]
async fn test_create_table() {
let mut server = Server::new_async().await;
Expand Down Expand Up @@ -4009,6 +4147,7 @@ mod tests {

let config_mock = create_config_mock(&mut server).await;

// GET hit once: the transaction refreshes the table before committing.
let load_table_mock = server
.mock("GET", "/v1/namespaces/ns1/tables/test1")
.with_status(200)
Expand All @@ -4017,6 +4156,7 @@ mod tests {
env!("CARGO_MANIFEST_DIR"),
"load_table_response.json"
))
.expect(1)
.create_async()
.await;

Expand Down Expand Up @@ -4273,6 +4413,56 @@ mod tests {
register_table_mock.assert_async().await;
}

/// The register response is a `LoadTableResult`, so the vended credentials
/// it carries have to reach the table's FileIO like `load_table`'s do.
#[tokio::test]
async fn test_register_table_uses_vended_credentials() {
let mut server = Server::new_async().await;
let config_mock = create_config_mock(&mut server).await;
let register_table_mock = server
.mock("POST", "/v1/namespaces/ns1/register")
.with_status(200)
.with_body_from_file(format!(
"{}/testdata/{}",
env!("CARGO_MANIFEST_DIR"),
"load_table_response_with_credentials.json"
))
.create_async()
.await;

let catalog = session_catalog(RestCatalogConfig::builder().uri(server.url()).build());
let table_ident = TableIdent::from_strs(["ns1", "test1"]).unwrap();
let table = catalog
.register_table(
&SessionContext::empty(),
&table_ident,
"s3://warehouse/database/table/metadata/00001-5f2f8166-244c-4eae-ac36-384ecdec81fc.gz.metadata.json".to_string(),
)
.await
.unwrap();

let vended = table
.file_io()
.config_for("s3://warehouse/database/table/data/f.parquet");
assert_eq!(
vended.get("s3.access-key-id").map(String::as_str),
Some("vended-key-id")
);
// Scoped to the prefix the server sent them for.
let outside = table
.file_io()
.config_for("s3://other-bucket/data/f.parquet");
assert_eq!(outside.get("s3.access-key-id"), None);
// The response's table config reaches the FileIO too, as in load_table.
assert_eq!(
table.file_io().config().get("region").map(String::as_str),
Some("us-west-2")
);

config_mock.assert_async().await;
register_table_mock.assert_async().await;
}

#[tokio::test]
async fn test_register_table_404() {
let mut server = Server::new_async().await;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
{
"metadata-location": "s3://warehouse/database/table/metadata/00001-5f2f8166-244c-4eae-ac36-384ecdec81fc.gz.metadata.json",
"metadata": {
"format-version": 1,
"table-uuid": "b55d9dda-6561-423a-8bfc-787980ce421f",
"location": "s3://warehouse/database/table",
"last-updated-ms": 1646787054459,
"last-column-id": 2,
"schema": {
"type": "struct",
"schema-id": 0,
"fields": [
{"id": 1, "name": "id", "required": false, "type": "int"},
{"id": 2, "name": "data", "required": false, "type": "string"}
]
},
"current-schema-id": 0,
"schemas": [
{
"type": "struct",
"schema-id": 0,
"fields": [
{"id": 1, "name": "id", "required": false, "type": "int"},
{"id": 2, "name": "data", "required": false, "type": "string"}
]
}
],
"partition-spec": [],
"default-spec-id": 0,
"partition-specs": [{"spec-id": 0, "fields": []}],
"last-partition-id": 999,
"default-sort-order-id": 0,
"sort-orders": [{"order-id": 0, "fields": []}],
"properties": {"owner": "bryan", "write.metadata.compression-codec": "gzip"},
"current-snapshot-id": 3497810964824022504,
"refs": {"main": {"snapshot-id": 3497810964824022504, "type": "branch"}},
"snapshots": [
{
"snapshot-id": 3497810964824022504,
"timestamp-ms": 1646787054459,
"summary": {
"operation": "append",
"spark.app.id": "local-1646787004168",
"added-data-files": "1",
"added-records": "1",
"added-files-size": "697",
"changed-partition-count": "1",
"total-records": "1",
"total-files-size": "697",
"total-data-files": "1",
"total-delete-files": "0",
"total-position-deletes": "0",
"total-equality-deletes": "0"
},
"manifest-list": "s3://warehouse/database/table/metadata/snap-3497810964824022504-1-c4f68204-666b-4e50-a9df-b10c34bf6b82.avro",
"schema-id": 0
}
],
"snapshot-log": [{"timestamp-ms": 1646787054459, "snapshot-id": 3497810964824022504}],
"metadata-log": [
{
"timestamp-ms": 1646787031514,
"metadata-file": "s3://warehouse/database/table/metadata/00000-88484a1c-00e5-4a07-a787-c0e7aeffa805.gz.metadata.json"
}
]
},
"config": {"client.factory": "io.tabular.iceberg.catalog.TabularAwsClientFactory", "region": "us-west-2"},
"storage-credentials": [
{
"prefix": "s3://warehouse/database/table",
"config": {
"s3.access-key-id": "vended-key-id",
"s3.secret-access-key": "vended-secret",
"s3.session-token": "vended-token"
}
}
]
}
Loading
Loading