feat(table-catalog): add credential endpoint boundary (#3407)

* feat(table-catalog): add credential scope boundary

* feat(table-catalog): add credential endpoint boundary

* fix(table-catalog): remove redundant scope clone

---------

Co-authored-by: Henry Guo <marshawcoco@users.noreply.github.com>
This commit is contained in:
Henry Guo
2026-06-13 21:26:59 +08:00
committed by GitHub
parent 6b1e114b39
commit 49a893f010
7 changed files with 220 additions and 6 deletions
+8
View File
@@ -534,6 +534,8 @@ pub enum AdminAction {
GetTableLifecycleAction,
#[strum(serialize = "admin:SetTableLifecycle")]
SetTableLifecycleAction,
#[strum(serialize = "admin:GetTableCredentials")]
GetTableCredentialsAction,
#[strum(serialize = "admin:RunTableMaintenance")]
RunTableMaintenanceAction,
#[strum(serialize = "admin:GetTableMetadataLocation")]
@@ -582,6 +584,7 @@ impl AdminAction {
| AdminAction::DeleteTableAction
| AdminAction::GetTableLifecycleAction
| AdminAction::SetTableLifecycleAction
| AdminAction::GetTableCredentialsAction
| AdminAction::RunTableMaintenanceAction
| AdminAction::GetTableMetadataLocationAction
| AdminAction::SetTableMetadataLocationAction
@@ -668,6 +671,7 @@ impl AdminAction {
| AdminAction::DeleteTableAction
| AdminAction::GetTableLifecycleAction
| AdminAction::SetTableLifecycleAction
| AdminAction::GetTableCredentialsAction
| AdminAction::RunTableMaintenanceAction
| AdminAction::GetTableMetadataLocationAction
| AdminAction::SetTableMetadataLocationAction
@@ -869,6 +873,8 @@ mod tests {
AdminAction::try_from("admin:SetTableLifecycle").expect("Should parse SetTableLifecycle action");
let run_maintenance_action =
AdminAction::try_from("admin:RunTableMaintenance").expect("Should parse RunTableMaintenance action");
let get_credentials_action =
AdminAction::try_from("admin:GetTableCredentials").expect("Should parse GetTableCredentials action");
assert_eq!(get_action, AdminAction::GetTableAction);
assert_eq!(set_action, AdminAction::SetTableAction);
@@ -879,6 +885,7 @@ mod tests {
assert_eq!(get_lifecycle_action, AdminAction::GetTableLifecycleAction);
assert_eq!(set_lifecycle_action, AdminAction::SetTableLifecycleAction);
assert_eq!(run_maintenance_action, AdminAction::RunTableMaintenanceAction);
assert_eq!(get_credentials_action, AdminAction::GetTableCredentialsAction);
assert!(get_action.is_valid());
assert!(set_action.is_valid());
assert!(create_action.is_valid());
@@ -888,6 +895,7 @@ mod tests {
assert!(get_lifecycle_action.is_valid());
assert!(set_lifecycle_action.is_valid());
assert!(run_maintenance_action.is_valid());
assert!(get_credentials_action.is_valid());
}
#[test]
+133 -2
View File
@@ -36,7 +36,15 @@ const WAREHOUSE_PROPERTY: &str = "warehouse";
const CATALOG_ENDPOINT_PREFIX_CONFIG_KEY: &str = "rustfs.catalog-endpoint-prefix";
const CATALOG_COMPAT_ENDPOINT_PREFIX_CONFIG_KEY: &str = "rustfs.catalog-compat-endpoint-prefix";
const CREDENTIAL_VENDING_CONFIG_KEY: &str = "rustfs.credential-vending";
const CREDENTIAL_VENDING_REASON_CONFIG_KEY: &str = "rustfs.credential-vending-reason";
const CREDENTIAL_SCOPE_CONFIG_KEY: &str = "rustfs.credential-scope";
const CREDENTIAL_SCOPE_PREFIX_CONFIG_KEY: &str = "rustfs.credential-scope-prefix";
const CREDENTIAL_MODE_CONFIG_KEY: &str = "rustfs.credential-mode";
const CREDENTIAL_VENDING_UNSUPPORTED: &str = "unsupported";
const CREDENTIAL_VENDING_UNSUPPORTED_REASON: &str = "temporary-credentials-not-implemented";
const CREDENTIAL_SCOPE_WAREHOUSE_PREFIX: &str = "warehouse-prefix";
const CREDENTIAL_SCOPE_TABLE_PREFIX: &str = "table-prefix";
const CREDENTIAL_MODE_CLIENT_PROVIDED: &str = "client-provided-s3-credentials-required";
const TABLE_CATALOG_NAMESPACE_RESOURCE_ROOT: &str = "namespaces";
const TABLE_CATALOG_TABLE_RESOURCE_ROOT: &str = "tables";
const TABLE_CATALOG_ENDPOINTS: &[&str] = &[
@@ -50,6 +58,7 @@ const TABLE_CATALOG_ENDPOINTS: &[&str] = &[
"POST /v1/{prefix}/namespaces/{namespace}/register",
"GET /v1/{prefix}/namespaces/{namespace}/tables/{table}",
"HEAD /v1/{prefix}/namespaces/{namespace}/tables/{table}",
"GET /v1/{prefix}/namespaces/{namespace}/tables/{table}/credentials",
"POST /v1/{prefix}/namespaces/{namespace}/tables/{table}",
"DELETE /v1/{prefix}/namespaces/{namespace}/tables/{table}",
"PUT /buckets/{warehouse}",
@@ -64,6 +73,7 @@ const TABLE_CATALOG_ENDPOINTS: &[&str] = &[
"POST /{warehouse}/namespaces/{namespace}/register",
"GET /{warehouse}/namespaces/{namespace}/tables/{table}",
"HEAD /{warehouse}/namespaces/{namespace}/tables/{table}",
"GET /{warehouse}/namespaces/{namespace}/tables/{table}/credentials",
"POST /{warehouse}/namespaces/{namespace}/tables/{table}",
"DELETE /{warehouse}/namespaces/{namespace}/tables/{table}",
"POST /{warehouse}/namespaces/{namespace}/tables/{table}/maintenance/metadata",
@@ -91,6 +101,7 @@ static CREATE_TABLE_HANDLER: RestCreateTableHandler = RestCreateTableHandler {};
static REGISTER_TABLE_HANDLER: RestRegisterTableHandler = RestRegisterTableHandler {};
static LOAD_TABLE_HANDLER: RestLoadTableHandler = RestLoadTableHandler {};
static TABLE_EXISTS_HANDLER: RestTableExistsHandler = RestTableExistsHandler {};
static LOAD_CREDENTIALS_HANDLER: RestLoadCredentialsHandler = RestLoadCredentialsHandler {};
static COMMIT_TABLE_HANDLER: RestCommitTableHandler = RestCommitTableHandler {};
static DROP_TABLE_HANDLER: RestDropTableHandler = RestDropTableHandler {};
static GET_TABLE_METADATA_LOCATION_HANDLER: GetTableMetadataLocationHandler = GetTableMetadataLocationHandler {};
@@ -231,6 +242,10 @@ struct TableBucketResponse {
compat_catalog_uri: String,
#[serde(rename = "credential-vending")]
credential_vending: &'static str,
#[serde(rename = "credential-scope")]
credential_scope: &'static str,
#[serde(rename = "credential-scope-prefix")]
credential_scope_prefix: String,
#[serde(rename = "catalog-entry-present")]
catalog_entry_present: bool,
properties: BTreeMap<String, String>,
@@ -274,6 +289,12 @@ struct RestLoadTableResponse {
storage_credentials: Vec<RestStorageCredential>,
}
#[derive(Debug, Serialize)]
struct RestLoadCredentialsResponse {
#[serde(rename = "storage-credentials")]
storage_credentials: Vec<RestStorageCredential>,
}
#[derive(Debug, Serialize)]
struct RestCommitTableResponse {
#[serde(rename = "metadata-location")]
@@ -367,6 +388,11 @@ fn register_table_catalog_prefix_routes(r: &mut S3Router<AdminOperation>, prefix
format!("{prefix}/{{warehouse}}/namespaces/{{namespace}}/tables/{{table}}").as_str(),
AdminOperation(&TABLE_EXISTS_HANDLER),
)?;
r.insert(
Method::GET,
format!("{prefix}/{{warehouse}}/namespaces/{{namespace}}/tables/{{table}}/credentials").as_str(),
AdminOperation(&LOAD_CREDENTIALS_HANDLER),
)?;
r.insert(
Method::POST,
format!("{prefix}/{{warehouse}}/namespaces/{{namespace}}/tables/{{table}}").as_str(),
@@ -663,10 +689,12 @@ where
enabled,
catalog_type,
warehouse: bucket.to_string(),
warehouse_location,
warehouse_location: warehouse_location.clone(),
catalog_uri: format!("{TABLE_CATALOG_PREFIX}/{bucket}"),
compat_catalog_uri: format!("{TABLE_CATALOG_COMPAT_PREFIX}/{bucket}"),
credential_vending: CREDENTIAL_VENDING_UNSUPPORTED,
credential_scope: CREDENTIAL_SCOPE_WAREHOUSE_PREFIX,
credential_scope_prefix: warehouse_location,
catalog_entry_present,
properties,
})
@@ -738,8 +766,16 @@ fn list_tables_response_from_entries(entries: Vec<crate::table_catalog::TableEnt
fn load_table_response_from_entry(entry: crate::table_catalog::TableEntry, metadata: serde_json::Value) -> RestLoadTableResponse {
let mut config = BTreeMap::new();
config.insert("warehouse-location".to_string(), entry.warehouse_location);
let warehouse_location = entry.warehouse_location.clone();
config.insert("warehouse-location".to_string(), warehouse_location.clone());
config.insert(CREDENTIAL_VENDING_CONFIG_KEY.to_string(), CREDENTIAL_VENDING_UNSUPPORTED.to_string());
config.insert(
CREDENTIAL_VENDING_REASON_CONFIG_KEY.to_string(),
CREDENTIAL_VENDING_UNSUPPORTED_REASON.to_string(),
);
config.insert(CREDENTIAL_SCOPE_CONFIG_KEY.to_string(), CREDENTIAL_SCOPE_TABLE_PREFIX.to_string());
config.insert(CREDENTIAL_SCOPE_PREFIX_CONFIG_KEY.to_string(), warehouse_location);
config.insert(CREDENTIAL_MODE_CONFIG_KEY.to_string(), CREDENTIAL_MODE_CLIENT_PROVIDED.to_string());
RestLoadTableResponse {
metadata_location: entry.metadata_location,
@@ -749,6 +785,12 @@ fn load_table_response_from_entry(entry: crate::table_catalog::TableEntry, metad
}
}
fn load_credentials_response_from_entry(_entry: crate::table_catalog::TableEntry) -> RestLoadCredentialsResponse {
RestLoadCredentialsResponse {
storage_credentials: Vec::new(),
}
}
fn commit_table_response_from_result(
result: crate::table_catalog::TableCommitResult,
metadata: serde_json::Value,
@@ -1819,6 +1861,25 @@ where
Ok(exists_status(exists))
}
async fn load_credentials_response<S>(
store: &S,
bucket: &str,
namespace: &crate::table_catalog::Namespace,
table: &str,
) -> S3Result<RestLoadCredentialsResponse>
where
S: crate::table_catalog::TableCatalogStore + ?Sized,
{
let Some(entry) = store
.load_table(bucket, &namespace.public_name(), table)
.await
.map_err(catalog_store_error)?
else {
return Err(s3_error!(InvalidRequest, "table not found"));
};
Ok(load_credentials_response_from_entry(entry))
}
async fn get_table_metadata_location_response<S>(
store: &S,
bucket: &str,
@@ -2285,6 +2346,22 @@ impl Operation for RestTableExistsHandler {
}
}
pub struct RestLoadCredentialsHandler {}
#[async_trait::async_trait]
impl Operation for RestLoadCredentialsHandler {
async fn call(&self, req: S3Request<Body>, params: Params<'_, '_>) -> S3Result<S3Response<(StatusCode, Body)>> {
let warehouse = warehouse_from_params(&params)?;
let namespace = namespace_from_params(&params)?;
let table = table_name_from_params(&params)?;
let resource = TableCatalogResource::table(&warehouse, &namespace, &table);
authorize_table_catalog_resource_request(&req, &resource, AdminAction::GetTableCredentialsAction).await?;
let store = table_catalog_store()?;
let response = load_credentials_response(&store, &warehouse, &namespace, &table).await?;
build_json_response(StatusCode::OK, &response)
}
}
pub struct RestCommitTableHandler {}
#[async_trait::async_trait]
@@ -2544,6 +2621,11 @@ mod tests {
.endpoints
.contains(&"HEAD /v1/{prefix}/namespaces/{namespace}/tables/{table}")
);
assert!(
response
.endpoints
.contains(&"GET /v1/{prefix}/namespaces/{namespace}/tables/{table}/credentials")
);
assert!(response.endpoints.contains(&"GET /{warehouse}/namespaces"));
assert!(response.endpoints.contains(&"POST /{warehouse}/namespaces"));
assert!(response.endpoints.contains(&"HEAD /{warehouse}/namespaces/{namespace}"));
@@ -2572,6 +2654,11 @@ mod tests {
.endpoints
.contains(&"POST /{warehouse}/namespaces/{namespace}/tables/{table}")
);
assert!(
response
.endpoints
.contains(&"GET /{warehouse}/namespaces/{namespace}/tables/{table}/credentials")
);
}
#[test]
@@ -2600,6 +2687,7 @@ mod tests {
("RestRegisterTableHandler", "AdminAction::RegisterTableAction"),
("RestLoadTableHandler", "AdminAction::GetTableMetadataAction"),
("RestTableExistsHandler", "AdminAction::GetTableAction"),
("RestLoadCredentialsHandler", "AdminAction::GetTableCredentialsAction"),
("RestCommitTableHandler", "AdminAction::CommitTableAction"),
("RestDropTableHandler", "AdminAction::DeleteTableAction"),
("GetTableMetadataLocationHandler", "AdminAction::GetTableMetadataLocationAction"),
@@ -2631,6 +2719,7 @@ mod tests {
for (handler, action) in [
("RestLoadTableHandler", "AdminAction::GetTableMetadataAction"),
("RestTableExistsHandler", "AdminAction::GetTableAction"),
("RestLoadCredentialsHandler", "AdminAction::GetTableCredentialsAction"),
("RestCommitTableHandler", "AdminAction::CommitTableAction"),
("RestDropTableHandler", "AdminAction::DeleteTableAction"),
("GetTableMetadataLocationHandler", "AdminAction::GetTableMetadataLocationAction"),
@@ -2702,6 +2791,7 @@ mod tests {
let _: &RestRegisterTableHandler = &REGISTER_TABLE_HANDLER;
let _: &RestLoadTableHandler = &LOAD_TABLE_HANDLER;
let _: &RestTableExistsHandler = &TABLE_EXISTS_HANDLER;
let _: &RestLoadCredentialsHandler = &LOAD_CREDENTIALS_HANDLER;
let _: &RestCommitTableHandler = &COMMIT_TABLE_HANDLER;
let _: &RestDropTableHandler = &DROP_TABLE_HANDLER;
let _: &GetTableMetadataLocationHandler = &GET_TABLE_METADATA_LOCATION_HANDLER;
@@ -2727,6 +2817,7 @@ mod tests {
assert_operation::<RestRegisterTableHandler>();
assert_operation::<RestLoadTableHandler>();
assert_operation::<RestTableExistsHandler>();
assert_operation::<RestLoadCredentialsHandler>();
assert_operation::<RestCommitTableHandler>();
assert_operation::<RestDropTableHandler>();
assert_operation::<GetTableMetadataLocationHandler>();
@@ -2780,6 +2871,8 @@ mod tests {
assert_eq!(response.catalog_uri, "/iceberg/v1/warehouse");
assert_eq!(response.compat_catalog_uri, "/_iceberg/v1/warehouse");
assert_eq!(response.credential_vending, CREDENTIAL_VENDING_UNSUPPORTED);
assert_eq!(response.credential_scope, "warehouse-prefix");
assert_eq!(response.credential_scope_prefix, "s3://warehouse/");
assert!(response.catalog_entry_present);
}
@@ -3612,11 +3705,49 @@ mod tests {
assert_eq!(response.metadata, metadata);
assert!(response.storage_credentials.is_empty());
assert_eq!(response.config.get("rustfs.credential-vending"), Some(&"unsupported".to_string()));
assert_eq!(
response.config.get("rustfs.credential-vending-reason"),
Some(&"temporary-credentials-not-implemented".to_string())
);
assert_eq!(response.config.get("rustfs.credential-scope"), Some(&"table-prefix".to_string()));
assert_eq!(
response.config.get("rustfs.credential-scope-prefix"),
Some(&"s3://warehouse/tables/table-id".to_string())
);
assert_eq!(
response.config.get("rustfs.credential-mode"),
Some(&"client-provided-s3-credentials-required".to_string())
);
assert!(!response.config.contains_key("s3.access-key-id"));
assert!(!response.config.contains_key("s3.secret-access-key"));
assert!(!response.config.contains_key("s3.session-token"));
}
#[test]
fn load_credentials_response_never_returns_static_s3_credentials() {
let response = load_credentials_response_from_entry(crate::table_catalog::TableEntry {
version: crate::table_catalog::TABLE_CATALOG_ENTRY_VERSION,
table_bucket: "warehouse".to_string(),
namespace: "analytics".to_string(),
table: "events".to_string(),
table_id: "table-id".to_string(),
table_uuid: "table-uuid".to_string(),
format: "ICEBERG".to_string(),
format_version: 2,
warehouse_location: "s3://warehouse/tables/table-id".to_string(),
metadata_location: ".rustfs-table/warehouses/default/namespaces/analytics/tables/events/metadata/00001.metadata.json"
.to_string(),
version_token: "token-v1".to_string(),
generation: 1,
state: crate::table_catalog::TableCatalogEntryState::Active,
properties: BTreeMap::new(),
created_at: None,
updated_at: None,
});
assert!(response.storage_credentials.is_empty());
}
#[test]
fn commit_table_request_uses_rest_commit_fields() {
let request: RestCommitTableRequest = serde_json::from_value(serde_json::json!({
+29 -1
View File
@@ -41,6 +41,7 @@ const GET_REPLICATION_METRICS: AdminActionRef = AdminActionRef::new("GetReplicat
const GET_TABLE: AdminActionRef = AdminActionRef::new("GetTableAction");
const GET_TABLE_BUCKET: AdminActionRef = AdminActionRef::new("GetTableBucketAction");
const GET_TABLE_CATALOG: AdminActionRef = AdminActionRef::new("GetTableCatalogAction");
const GET_TABLE_CREDENTIALS: AdminActionRef = AdminActionRef::new("GetTableCredentialsAction");
const GET_TABLE_LIFECYCLE: AdminActionRef = AdminActionRef::new("GetTableLifecycleAction");
const GET_TABLE_METADATA: AdminActionRef = AdminActionRef::new("GetTableMetadataAction");
const GET_TABLE_METADATA_LOCATION: AdminActionRef = AdminActionRef::new("GetTableMetadataLocationAction");
@@ -675,6 +676,12 @@ pub const ADMIN_ROUTE_POLICY_SPECS: &[AdminRouteSpec] = &[
GET_TABLE,
RouteRiskLevel::Sensitive,
),
admin(
HttpMethod::Get,
"/iceberg/v1/{warehouse}/namespaces/{namespace}/tables/{table}/credentials",
GET_TABLE_CREDENTIALS,
RouteRiskLevel::Sensitive,
),
admin(
HttpMethod::Post,
"/iceberg/v1/{warehouse}/namespaces/{namespace}/tables/{table}",
@@ -820,6 +827,12 @@ pub const ADMIN_ROUTE_POLICY_SPECS: &[AdminRouteSpec] = &[
GET_TABLE,
RouteRiskLevel::Sensitive,
),
admin(
HttpMethod::Get,
"/_iceberg/v1/{warehouse}/namespaces/{namespace}/tables/{table}/credentials",
GET_TABLE_CREDENTIALS,
RouteRiskLevel::Sensitive,
),
admin(
HttpMethod::Post,
"/_iceberg/v1/{warehouse}/namespaces/{namespace}/tables/{table}",
@@ -1065,7 +1078,7 @@ mod tests {
let table_specs = ADMIN_ROUTE_POLICY_SPECS
.iter()
.filter(|spec| spec.path().starts_with("/iceberg/v1") || spec.path().starts_with("/_iceberg/v1"));
assert_eq!(table_specs.count(), 50);
assert_eq!(table_specs.count(), 52);
assert_action(HttpMethod::Put, "/iceberg/v1/buckets/{warehouse}", SET_TABLE_BUCKET);
assert_action(HttpMethod::Get, "/_iceberg/v1/buckets/{warehouse}", GET_TABLE_BUCKET);
assert_action(HttpMethod::Get, "/iceberg/v1/{warehouse}/namespaces", GET_TABLE_NAMESPACE);
@@ -1079,6 +1092,11 @@ mod tests {
"/iceberg/v1/{warehouse}/namespaces/{namespace}/tables/{table}",
GET_TABLE_METADATA,
);
assert_action(
HttpMethod::Get,
"/iceberg/v1/{warehouse}/namespaces/{namespace}/tables/{table}/credentials",
GET_TABLE_CREDENTIALS,
);
assert_action(
HttpMethod::Get,
"/_iceberg/v1/{warehouse}/namespaces/{namespace}/tables/{table}",
@@ -1094,6 +1112,16 @@ mod tests {
"/_iceberg/v1/{warehouse}/namespaces/{namespace}/tables/{table}",
GET_TABLE,
);
assert_action(
HttpMethod::Get,
"/_iceberg/v1/{warehouse}/namespaces/{namespace}/tables/{table}/credentials",
GET_TABLE_CREDENTIALS,
);
assert_action(
HttpMethod::Get,
"/iceberg/v1/{warehouse}/namespaces/{namespace}/tables/{table}/credentials",
GET_TABLE_CREDENTIALS,
);
assert_action(
HttpMethod::Post,
"/iceberg/v1/{warehouse}/namespaces/{namespace}/tables/{table}",
@@ -315,6 +315,11 @@ fn expected_admin_route_matrix() -> Vec<RouteMatrixEntry> {
"/{warehouse}/namespaces/{namespace}/tables/{table}",
"/analytics/namespaces/sales/tables/orders",
),
table_route_sample(
Method::GET,
"/{warehouse}/namespaces/{namespace}/tables/{table}/credentials",
"/analytics/namespaces/sales/tables/orders/credentials",
),
table_route_sample(
Method::POST,
"/{warehouse}/namespaces/{namespace}/tables/{table}",
@@ -408,6 +413,11 @@ fn expected_admin_route_matrix() -> Vec<RouteMatrixEntry> {
"/{warehouse}/namespaces/{namespace}/tables/{table}",
"/analytics/namespaces/sales/tables/orders",
),
compat_table_route_sample(
Method::GET,
"/{warehouse}/namespaces/{namespace}/tables/{table}/credentials",
"/analytics/namespaces/sales/tables/orders/credentials",
),
compat_table_route_sample(
Method::POST,
"/{warehouse}/namespaces/{namespace}/tables/{table}",
@@ -587,6 +597,11 @@ fn test_register_routes_cover_representative_admin_paths() {
assert_route(&router, Method::POST, &table_catalog_path("/analytics/namespaces/sales/tables"));
assert_route(&router, Method::POST, &table_catalog_path("/analytics/namespaces/sales/register"));
assert_route(&router, Method::GET, &table_catalog_path("/analytics/namespaces/sales/tables/orders"));
assert_route(
&router,
Method::GET,
&table_catalog_path("/analytics/namespaces/sales/tables/orders/credentials"),
);
assert_route(&router, Method::POST, &table_catalog_path("/analytics/namespaces/sales/tables/orders"));
assert_route(&router, Method::DELETE, &table_catalog_path("/analytics/namespaces/sales/tables/orders"));
assert_route(
@@ -654,6 +669,11 @@ fn test_register_routes_cover_representative_admin_paths() {
Method::GET,
&compat_table_catalog_path("/analytics/namespaces/sales/tables/orders"),
);
assert_route(
&router,
Method::GET,
&compat_table_catalog_path("/analytics/namespaces/sales/tables/orders/credentials"),
);
assert_route(
&router,
Method::POST,
+15 -1
View File
@@ -112,13 +112,27 @@ added.
Unsupported behavior is documented instead of hidden behind internal errors. The
current unsupported inventory is:
- credential vending: unsupported response boundary exists; real temporary credentials are not issued
- credential vending: non-secret table scope preview and credentials endpoint exist; real temporary credentials are not issued
- background maintenance worker: unsupported
- manifest/data reachability cleanup: unsupported
- snapshot expiration and compaction: unsupported
- Iceberg views: unsupported
- multi-table transactions: not a short-term production claim
## Credential Boundary
RustFS advertises table credential scope metadata without returning reusable
storage secrets. `loadTable` includes the table warehouse prefix in the response
config, and the standard credentials endpoint is registered:
```text
GET /v1/{prefix}/namespaces/{namespace}/tables/{table}/credentials
```
The endpoint returns an empty `storage-credentials` list until temporary,
table-scoped credential issuance is implemented. Clients should continue using
their configured S3 credentials for object data access.
## Spark Manual Baseline
Spark validation should use the same RustFS endpoint and warehouse bucket as the
+3 -2
View File
@@ -111,9 +111,10 @@ CLIENT_MATRIX: list[dict[str, str]] = [
UNSUPPORTED_INVENTORY: list[dict[str, str]] = [
{
"capability": "credential-vending",
"status": "unsupported-response-boundary",
"status": "scope-preview-no-temporary-credentials",
"roadmap_area": "credential-boundary",
"expected_behavior": "load table advertises rustfs.credential-vending=unsupported and returns no long-lived credentials",
"catalog_endpoint": "GET /v1/{prefix}/namespaces/{namespace}/tables/{table}/credentials",
"expected_behavior": "load table advertises the table credential scope; the credentials endpoint returns an empty storage-credentials list and no long-lived credentials",
},
{
"capability": "background-maintenance-worker",
@@ -4,13 +4,19 @@
from __future__ import annotations
import os
import re
import sys
import unittest
from pathlib import Path
from unittest import mock
import pyiceberg_smoke
INTERNAL_ROADMAP_LABEL_RE = re.compile(r"\b(?:PR|PG|P)[0-9]+\b")
SCRIPT_DIR = Path(__file__).resolve().parent
class PyIcebergSmokeConfigTest(unittest.TestCase):
def parse_with_args(self, argv: list[str]) -> object:
env_keys = [
@@ -70,6 +76,12 @@ class PyIcebergSmokeConfigTest(unittest.TestCase):
self.assertIn("status", entry)
self.assertIn("roadmap_area", entry)
def test_published_table_catalog_docs_do_not_use_internal_roadmap_labels(self) -> None:
readme = (SCRIPT_DIR / "README.md").read_text(encoding="utf-8")
self.assertIsNone(INTERNAL_ROADMAP_LABEL_RE.search(readme))
self.assertIsNone(INTERNAL_ROADMAP_LABEL_RE.search(str(pyiceberg_smoke.unsupported_inventory())))
if __name__ == "__main__":
unittest.main()