diff --git a/crates/policy/src/policy/action.rs b/crates/policy/src/policy/action.rs index 4eed3ab46..148e40d06 100644 --- a/crates/policy/src/policy/action.rs +++ b/crates/policy/src/policy/action.rs @@ -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] diff --git a/rustfs/src/admin/handlers/table_catalog.rs b/rustfs/src/admin/handlers/table_catalog.rs index 78e5d9716..6470557b9 100644 --- a/rustfs/src/admin/handlers/table_catalog.rs +++ b/rustfs/src/admin/handlers/table_catalog.rs @@ -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, @@ -274,6 +289,12 @@ struct RestLoadTableResponse { storage_credentials: Vec, } +#[derive(Debug, Serialize)] +struct RestLoadCredentialsResponse { + #[serde(rename = "storage-credentials")] + storage_credentials: Vec, +} + #[derive(Debug, Serialize)] struct RestCommitTableResponse { #[serde(rename = "metadata-location")] @@ -367,6 +388,11 @@ fn register_table_catalog_prefix_routes(r: &mut S3Router, 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 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( + store: &S, + bucket: &str, + namespace: &crate::table_catalog::Namespace, + table: &str, +) -> S3Result +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( 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, params: Params<'_, '_>) -> S3Result> { + let warehouse = warehouse_from_params(¶ms)?; + let namespace = namespace_from_params(¶ms)?; + let table = table_name_from_params(¶ms)?; + 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 = ®ISTER_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::(); assert_operation::(); assert_operation::(); + assert_operation::(); assert_operation::(); assert_operation::(); assert_operation::(); @@ -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!({ diff --git a/rustfs/src/admin/route_policy.rs b/rustfs/src/admin/route_policy.rs index 7dbe8b2b4..4d06ae929 100644 --- a/rustfs/src/admin/route_policy.rs +++ b/rustfs/src/admin/route_policy.rs @@ -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}", diff --git a/rustfs/src/admin/route_registration_test.rs b/rustfs/src/admin/route_registration_test.rs index 3fafe17db..a8d7b5c50 100644 --- a/rustfs/src/admin/route_registration_test.rs +++ b/rustfs/src/admin/route_registration_test.rs @@ -315,6 +315,11 @@ fn expected_admin_route_matrix() -> Vec { "/{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 { "/{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, diff --git a/scripts/table-catalog/README.md b/scripts/table-catalog/README.md index f7222cfcd..485ef1238 100644 --- a/scripts/table-catalog/README.md +++ b/scripts/table-catalog/README.md @@ -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 diff --git a/scripts/table-catalog/pyiceberg_smoke.py b/scripts/table-catalog/pyiceberg_smoke.py index 0f9dff59c..da1f4e6d2 100755 --- a/scripts/table-catalog/pyiceberg_smoke.py +++ b/scripts/table-catalog/pyiceberg_smoke.py @@ -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", diff --git a/scripts/table-catalog/test_pyiceberg_smoke.py b/scripts/table-catalog/test_pyiceberg_smoke.py index fa9879266..f5344b484 100644 --- a/scripts/table-catalog/test_pyiceberg_smoke.py +++ b/scripts/table-catalog/test_pyiceberg_smoke.py @@ -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()