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, GetTableLifecycleAction,
#[strum(serialize = "admin:SetTableLifecycle")] #[strum(serialize = "admin:SetTableLifecycle")]
SetTableLifecycleAction, SetTableLifecycleAction,
#[strum(serialize = "admin:GetTableCredentials")]
GetTableCredentialsAction,
#[strum(serialize = "admin:RunTableMaintenance")] #[strum(serialize = "admin:RunTableMaintenance")]
RunTableMaintenanceAction, RunTableMaintenanceAction,
#[strum(serialize = "admin:GetTableMetadataLocation")] #[strum(serialize = "admin:GetTableMetadataLocation")]
@@ -582,6 +584,7 @@ impl AdminAction {
| AdminAction::DeleteTableAction | AdminAction::DeleteTableAction
| AdminAction::GetTableLifecycleAction | AdminAction::GetTableLifecycleAction
| AdminAction::SetTableLifecycleAction | AdminAction::SetTableLifecycleAction
| AdminAction::GetTableCredentialsAction
| AdminAction::RunTableMaintenanceAction | AdminAction::RunTableMaintenanceAction
| AdminAction::GetTableMetadataLocationAction | AdminAction::GetTableMetadataLocationAction
| AdminAction::SetTableMetadataLocationAction | AdminAction::SetTableMetadataLocationAction
@@ -668,6 +671,7 @@ impl AdminAction {
| AdminAction::DeleteTableAction | AdminAction::DeleteTableAction
| AdminAction::GetTableLifecycleAction | AdminAction::GetTableLifecycleAction
| AdminAction::SetTableLifecycleAction | AdminAction::SetTableLifecycleAction
| AdminAction::GetTableCredentialsAction
| AdminAction::RunTableMaintenanceAction | AdminAction::RunTableMaintenanceAction
| AdminAction::GetTableMetadataLocationAction | AdminAction::GetTableMetadataLocationAction
| AdminAction::SetTableMetadataLocationAction | AdminAction::SetTableMetadataLocationAction
@@ -869,6 +873,8 @@ mod tests {
AdminAction::try_from("admin:SetTableLifecycle").expect("Should parse SetTableLifecycle action"); AdminAction::try_from("admin:SetTableLifecycle").expect("Should parse SetTableLifecycle action");
let run_maintenance_action = let run_maintenance_action =
AdminAction::try_from("admin:RunTableMaintenance").expect("Should parse RunTableMaintenance 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!(get_action, AdminAction::GetTableAction);
assert_eq!(set_action, AdminAction::SetTableAction); assert_eq!(set_action, AdminAction::SetTableAction);
@@ -879,6 +885,7 @@ mod tests {
assert_eq!(get_lifecycle_action, AdminAction::GetTableLifecycleAction); assert_eq!(get_lifecycle_action, AdminAction::GetTableLifecycleAction);
assert_eq!(set_lifecycle_action, AdminAction::SetTableLifecycleAction); assert_eq!(set_lifecycle_action, AdminAction::SetTableLifecycleAction);
assert_eq!(run_maintenance_action, AdminAction::RunTableMaintenanceAction); assert_eq!(run_maintenance_action, AdminAction::RunTableMaintenanceAction);
assert_eq!(get_credentials_action, AdminAction::GetTableCredentialsAction);
assert!(get_action.is_valid()); assert!(get_action.is_valid());
assert!(set_action.is_valid()); assert!(set_action.is_valid());
assert!(create_action.is_valid()); assert!(create_action.is_valid());
@@ -888,6 +895,7 @@ mod tests {
assert!(get_lifecycle_action.is_valid()); assert!(get_lifecycle_action.is_valid());
assert!(set_lifecycle_action.is_valid()); assert!(set_lifecycle_action.is_valid());
assert!(run_maintenance_action.is_valid()); assert!(run_maintenance_action.is_valid());
assert!(get_credentials_action.is_valid());
} }
#[test] #[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_ENDPOINT_PREFIX_CONFIG_KEY: &str = "rustfs.catalog-endpoint-prefix";
const CATALOG_COMPAT_ENDPOINT_PREFIX_CONFIG_KEY: &str = "rustfs.catalog-compat-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_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: &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_NAMESPACE_RESOURCE_ROOT: &str = "namespaces";
const TABLE_CATALOG_TABLE_RESOURCE_ROOT: &str = "tables"; const TABLE_CATALOG_TABLE_RESOURCE_ROOT: &str = "tables";
const TABLE_CATALOG_ENDPOINTS: &[&str] = &[ const TABLE_CATALOG_ENDPOINTS: &[&str] = &[
@@ -50,6 +58,7 @@ const TABLE_CATALOG_ENDPOINTS: &[&str] = &[
"POST /v1/{prefix}/namespaces/{namespace}/register", "POST /v1/{prefix}/namespaces/{namespace}/register",
"GET /v1/{prefix}/namespaces/{namespace}/tables/{table}", "GET /v1/{prefix}/namespaces/{namespace}/tables/{table}",
"HEAD /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}", "POST /v1/{prefix}/namespaces/{namespace}/tables/{table}",
"DELETE /v1/{prefix}/namespaces/{namespace}/tables/{table}", "DELETE /v1/{prefix}/namespaces/{namespace}/tables/{table}",
"PUT /buckets/{warehouse}", "PUT /buckets/{warehouse}",
@@ -64,6 +73,7 @@ const TABLE_CATALOG_ENDPOINTS: &[&str] = &[
"POST /{warehouse}/namespaces/{namespace}/register", "POST /{warehouse}/namespaces/{namespace}/register",
"GET /{warehouse}/namespaces/{namespace}/tables/{table}", "GET /{warehouse}/namespaces/{namespace}/tables/{table}",
"HEAD /{warehouse}/namespaces/{namespace}/tables/{table}", "HEAD /{warehouse}/namespaces/{namespace}/tables/{table}",
"GET /{warehouse}/namespaces/{namespace}/tables/{table}/credentials",
"POST /{warehouse}/namespaces/{namespace}/tables/{table}", "POST /{warehouse}/namespaces/{namespace}/tables/{table}",
"DELETE /{warehouse}/namespaces/{namespace}/tables/{table}", "DELETE /{warehouse}/namespaces/{namespace}/tables/{table}",
"POST /{warehouse}/namespaces/{namespace}/tables/{table}/maintenance/metadata", "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 REGISTER_TABLE_HANDLER: RestRegisterTableHandler = RestRegisterTableHandler {};
static LOAD_TABLE_HANDLER: RestLoadTableHandler = RestLoadTableHandler {}; static LOAD_TABLE_HANDLER: RestLoadTableHandler = RestLoadTableHandler {};
static TABLE_EXISTS_HANDLER: RestTableExistsHandler = RestTableExistsHandler {}; static TABLE_EXISTS_HANDLER: RestTableExistsHandler = RestTableExistsHandler {};
static LOAD_CREDENTIALS_HANDLER: RestLoadCredentialsHandler = RestLoadCredentialsHandler {};
static COMMIT_TABLE_HANDLER: RestCommitTableHandler = RestCommitTableHandler {}; static COMMIT_TABLE_HANDLER: RestCommitTableHandler = RestCommitTableHandler {};
static DROP_TABLE_HANDLER: RestDropTableHandler = RestDropTableHandler {}; static DROP_TABLE_HANDLER: RestDropTableHandler = RestDropTableHandler {};
static GET_TABLE_METADATA_LOCATION_HANDLER: GetTableMetadataLocationHandler = GetTableMetadataLocationHandler {}; static GET_TABLE_METADATA_LOCATION_HANDLER: GetTableMetadataLocationHandler = GetTableMetadataLocationHandler {};
@@ -231,6 +242,10 @@ struct TableBucketResponse {
compat_catalog_uri: String, compat_catalog_uri: String,
#[serde(rename = "credential-vending")] #[serde(rename = "credential-vending")]
credential_vending: &'static str, 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")] #[serde(rename = "catalog-entry-present")]
catalog_entry_present: bool, catalog_entry_present: bool,
properties: BTreeMap<String, String>, properties: BTreeMap<String, String>,
@@ -274,6 +289,12 @@ struct RestLoadTableResponse {
storage_credentials: Vec<RestStorageCredential>, storage_credentials: Vec<RestStorageCredential>,
} }
#[derive(Debug, Serialize)]
struct RestLoadCredentialsResponse {
#[serde(rename = "storage-credentials")]
storage_credentials: Vec<RestStorageCredential>,
}
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
struct RestCommitTableResponse { struct RestCommitTableResponse {
#[serde(rename = "metadata-location")] #[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(), format!("{prefix}/{{warehouse}}/namespaces/{{namespace}}/tables/{{table}}").as_str(),
AdminOperation(&TABLE_EXISTS_HANDLER), AdminOperation(&TABLE_EXISTS_HANDLER),
)?; )?;
r.insert(
Method::GET,
format!("{prefix}/{{warehouse}}/namespaces/{{namespace}}/tables/{{table}}/credentials").as_str(),
AdminOperation(&LOAD_CREDENTIALS_HANDLER),
)?;
r.insert( r.insert(
Method::POST, Method::POST,
format!("{prefix}/{{warehouse}}/namespaces/{{namespace}}/tables/{{table}}").as_str(), format!("{prefix}/{{warehouse}}/namespaces/{{namespace}}/tables/{{table}}").as_str(),
@@ -663,10 +689,12 @@ where
enabled, enabled,
catalog_type, catalog_type,
warehouse: bucket.to_string(), warehouse: bucket.to_string(),
warehouse_location, warehouse_location: warehouse_location.clone(),
catalog_uri: format!("{TABLE_CATALOG_PREFIX}/{bucket}"), catalog_uri: format!("{TABLE_CATALOG_PREFIX}/{bucket}"),
compat_catalog_uri: format!("{TABLE_CATALOG_COMPAT_PREFIX}/{bucket}"), compat_catalog_uri: format!("{TABLE_CATALOG_COMPAT_PREFIX}/{bucket}"),
credential_vending: CREDENTIAL_VENDING_UNSUPPORTED, credential_vending: CREDENTIAL_VENDING_UNSUPPORTED,
credential_scope: CREDENTIAL_SCOPE_WAREHOUSE_PREFIX,
credential_scope_prefix: warehouse_location,
catalog_entry_present, catalog_entry_present,
properties, 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 { fn load_table_response_from_entry(entry: crate::table_catalog::TableEntry, metadata: serde_json::Value) -> RestLoadTableResponse {
let mut config = BTreeMap::new(); 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_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 { RestLoadTableResponse {
metadata_location: entry.metadata_location, 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( fn commit_table_response_from_result(
result: crate::table_catalog::TableCommitResult, result: crate::table_catalog::TableCommitResult,
metadata: serde_json::Value, metadata: serde_json::Value,
@@ -1819,6 +1861,25 @@ where
Ok(exists_status(exists)) 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>( async fn get_table_metadata_location_response<S>(
store: &S, store: &S,
bucket: &str, 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 {} pub struct RestCommitTableHandler {}
#[async_trait::async_trait] #[async_trait::async_trait]
@@ -2544,6 +2621,11 @@ mod tests {
.endpoints .endpoints
.contains(&"HEAD /v1/{prefix}/namespaces/{namespace}/tables/{table}") .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(&"GET /{warehouse}/namespaces"));
assert!(response.endpoints.contains(&"POST /{warehouse}/namespaces")); assert!(response.endpoints.contains(&"POST /{warehouse}/namespaces"));
assert!(response.endpoints.contains(&"HEAD /{warehouse}/namespaces/{namespace}")); assert!(response.endpoints.contains(&"HEAD /{warehouse}/namespaces/{namespace}"));
@@ -2572,6 +2654,11 @@ mod tests {
.endpoints .endpoints
.contains(&"POST /{warehouse}/namespaces/{namespace}/tables/{table}") .contains(&"POST /{warehouse}/namespaces/{namespace}/tables/{table}")
); );
assert!(
response
.endpoints
.contains(&"GET /{warehouse}/namespaces/{namespace}/tables/{table}/credentials")
);
} }
#[test] #[test]
@@ -2600,6 +2687,7 @@ mod tests {
("RestRegisterTableHandler", "AdminAction::RegisterTableAction"), ("RestRegisterTableHandler", "AdminAction::RegisterTableAction"),
("RestLoadTableHandler", "AdminAction::GetTableMetadataAction"), ("RestLoadTableHandler", "AdminAction::GetTableMetadataAction"),
("RestTableExistsHandler", "AdminAction::GetTableAction"), ("RestTableExistsHandler", "AdminAction::GetTableAction"),
("RestLoadCredentialsHandler", "AdminAction::GetTableCredentialsAction"),
("RestCommitTableHandler", "AdminAction::CommitTableAction"), ("RestCommitTableHandler", "AdminAction::CommitTableAction"),
("RestDropTableHandler", "AdminAction::DeleteTableAction"), ("RestDropTableHandler", "AdminAction::DeleteTableAction"),
("GetTableMetadataLocationHandler", "AdminAction::GetTableMetadataLocationAction"), ("GetTableMetadataLocationHandler", "AdminAction::GetTableMetadataLocationAction"),
@@ -2631,6 +2719,7 @@ mod tests {
for (handler, action) in [ for (handler, action) in [
("RestLoadTableHandler", "AdminAction::GetTableMetadataAction"), ("RestLoadTableHandler", "AdminAction::GetTableMetadataAction"),
("RestTableExistsHandler", "AdminAction::GetTableAction"), ("RestTableExistsHandler", "AdminAction::GetTableAction"),
("RestLoadCredentialsHandler", "AdminAction::GetTableCredentialsAction"),
("RestCommitTableHandler", "AdminAction::CommitTableAction"), ("RestCommitTableHandler", "AdminAction::CommitTableAction"),
("RestDropTableHandler", "AdminAction::DeleteTableAction"), ("RestDropTableHandler", "AdminAction::DeleteTableAction"),
("GetTableMetadataLocationHandler", "AdminAction::GetTableMetadataLocationAction"), ("GetTableMetadataLocationHandler", "AdminAction::GetTableMetadataLocationAction"),
@@ -2702,6 +2791,7 @@ mod tests {
let _: &RestRegisterTableHandler = &REGISTER_TABLE_HANDLER; let _: &RestRegisterTableHandler = &REGISTER_TABLE_HANDLER;
let _: &RestLoadTableHandler = &LOAD_TABLE_HANDLER; let _: &RestLoadTableHandler = &LOAD_TABLE_HANDLER;
let _: &RestTableExistsHandler = &TABLE_EXISTS_HANDLER; let _: &RestTableExistsHandler = &TABLE_EXISTS_HANDLER;
let _: &RestLoadCredentialsHandler = &LOAD_CREDENTIALS_HANDLER;
let _: &RestCommitTableHandler = &COMMIT_TABLE_HANDLER; let _: &RestCommitTableHandler = &COMMIT_TABLE_HANDLER;
let _: &RestDropTableHandler = &DROP_TABLE_HANDLER; let _: &RestDropTableHandler = &DROP_TABLE_HANDLER;
let _: &GetTableMetadataLocationHandler = &GET_TABLE_METADATA_LOCATION_HANDLER; let _: &GetTableMetadataLocationHandler = &GET_TABLE_METADATA_LOCATION_HANDLER;
@@ -2727,6 +2817,7 @@ mod tests {
assert_operation::<RestRegisterTableHandler>(); assert_operation::<RestRegisterTableHandler>();
assert_operation::<RestLoadTableHandler>(); assert_operation::<RestLoadTableHandler>();
assert_operation::<RestTableExistsHandler>(); assert_operation::<RestTableExistsHandler>();
assert_operation::<RestLoadCredentialsHandler>();
assert_operation::<RestCommitTableHandler>(); assert_operation::<RestCommitTableHandler>();
assert_operation::<RestDropTableHandler>(); assert_operation::<RestDropTableHandler>();
assert_operation::<GetTableMetadataLocationHandler>(); assert_operation::<GetTableMetadataLocationHandler>();
@@ -2780,6 +2871,8 @@ mod tests {
assert_eq!(response.catalog_uri, "/iceberg/v1/warehouse"); assert_eq!(response.catalog_uri, "/iceberg/v1/warehouse");
assert_eq!(response.compat_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_vending, CREDENTIAL_VENDING_UNSUPPORTED);
assert_eq!(response.credential_scope, "warehouse-prefix");
assert_eq!(response.credential_scope_prefix, "s3://warehouse/");
assert!(response.catalog_entry_present); assert!(response.catalog_entry_present);
} }
@@ -3612,11 +3705,49 @@ mod tests {
assert_eq!(response.metadata, metadata); assert_eq!(response.metadata, metadata);
assert!(response.storage_credentials.is_empty()); 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"), 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.access-key-id"));
assert!(!response.config.contains_key("s3.secret-access-key")); assert!(!response.config.contains_key("s3.secret-access-key"));
assert!(!response.config.contains_key("s3.session-token")); 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] #[test]
fn commit_table_request_uses_rest_commit_fields() { fn commit_table_request_uses_rest_commit_fields() {
let request: RestCommitTableRequest = serde_json::from_value(serde_json::json!({ 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: AdminActionRef = AdminActionRef::new("GetTableAction");
const GET_TABLE_BUCKET: AdminActionRef = AdminActionRef::new("GetTableBucketAction"); const GET_TABLE_BUCKET: AdminActionRef = AdminActionRef::new("GetTableBucketAction");
const GET_TABLE_CATALOG: AdminActionRef = AdminActionRef::new("GetTableCatalogAction"); 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_LIFECYCLE: AdminActionRef = AdminActionRef::new("GetTableLifecycleAction");
const GET_TABLE_METADATA: AdminActionRef = AdminActionRef::new("GetTableMetadataAction"); const GET_TABLE_METADATA: AdminActionRef = AdminActionRef::new("GetTableMetadataAction");
const GET_TABLE_METADATA_LOCATION: AdminActionRef = AdminActionRef::new("GetTableMetadataLocationAction"); const GET_TABLE_METADATA_LOCATION: AdminActionRef = AdminActionRef::new("GetTableMetadataLocationAction");
@@ -675,6 +676,12 @@ pub const ADMIN_ROUTE_POLICY_SPECS: &[AdminRouteSpec] = &[
GET_TABLE, GET_TABLE,
RouteRiskLevel::Sensitive, RouteRiskLevel::Sensitive,
), ),
admin(
HttpMethod::Get,
"/iceberg/v1/{warehouse}/namespaces/{namespace}/tables/{table}/credentials",
GET_TABLE_CREDENTIALS,
RouteRiskLevel::Sensitive,
),
admin( admin(
HttpMethod::Post, HttpMethod::Post,
"/iceberg/v1/{warehouse}/namespaces/{namespace}/tables/{table}", "/iceberg/v1/{warehouse}/namespaces/{namespace}/tables/{table}",
@@ -820,6 +827,12 @@ pub const ADMIN_ROUTE_POLICY_SPECS: &[AdminRouteSpec] = &[
GET_TABLE, GET_TABLE,
RouteRiskLevel::Sensitive, RouteRiskLevel::Sensitive,
), ),
admin(
HttpMethod::Get,
"/_iceberg/v1/{warehouse}/namespaces/{namespace}/tables/{table}/credentials",
GET_TABLE_CREDENTIALS,
RouteRiskLevel::Sensitive,
),
admin( admin(
HttpMethod::Post, HttpMethod::Post,
"/_iceberg/v1/{warehouse}/namespaces/{namespace}/tables/{table}", "/_iceberg/v1/{warehouse}/namespaces/{namespace}/tables/{table}",
@@ -1065,7 +1078,7 @@ mod tests {
let table_specs = ADMIN_ROUTE_POLICY_SPECS let table_specs = ADMIN_ROUTE_POLICY_SPECS
.iter() .iter()
.filter(|spec| spec.path().starts_with("/iceberg/v1") || spec.path().starts_with("/_iceberg/v1")); .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::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/buckets/{warehouse}", GET_TABLE_BUCKET);
assert_action(HttpMethod::Get, "/iceberg/v1/{warehouse}/namespaces", GET_TABLE_NAMESPACE); assert_action(HttpMethod::Get, "/iceberg/v1/{warehouse}/namespaces", GET_TABLE_NAMESPACE);
@@ -1079,6 +1092,11 @@ mod tests {
"/iceberg/v1/{warehouse}/namespaces/{namespace}/tables/{table}", "/iceberg/v1/{warehouse}/namespaces/{namespace}/tables/{table}",
GET_TABLE_METADATA, GET_TABLE_METADATA,
); );
assert_action(
HttpMethod::Get,
"/iceberg/v1/{warehouse}/namespaces/{namespace}/tables/{table}/credentials",
GET_TABLE_CREDENTIALS,
);
assert_action( assert_action(
HttpMethod::Get, HttpMethod::Get,
"/_iceberg/v1/{warehouse}/namespaces/{namespace}/tables/{table}", "/_iceberg/v1/{warehouse}/namespaces/{namespace}/tables/{table}",
@@ -1094,6 +1112,16 @@ mod tests {
"/_iceberg/v1/{warehouse}/namespaces/{namespace}/tables/{table}", "/_iceberg/v1/{warehouse}/namespaces/{namespace}/tables/{table}",
GET_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( assert_action(
HttpMethod::Post, HttpMethod::Post,
"/iceberg/v1/{warehouse}/namespaces/{namespace}/tables/{table}", "/iceberg/v1/{warehouse}/namespaces/{namespace}/tables/{table}",
@@ -315,6 +315,11 @@ fn expected_admin_route_matrix() -> Vec<RouteMatrixEntry> {
"/{warehouse}/namespaces/{namespace}/tables/{table}", "/{warehouse}/namespaces/{namespace}/tables/{table}",
"/analytics/namespaces/sales/tables/orders", "/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( table_route_sample(
Method::POST, Method::POST,
"/{warehouse}/namespaces/{namespace}/tables/{table}", "/{warehouse}/namespaces/{namespace}/tables/{table}",
@@ -408,6 +413,11 @@ fn expected_admin_route_matrix() -> Vec<RouteMatrixEntry> {
"/{warehouse}/namespaces/{namespace}/tables/{table}", "/{warehouse}/namespaces/{namespace}/tables/{table}",
"/analytics/namespaces/sales/tables/orders", "/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( compat_table_route_sample(
Method::POST, Method::POST,
"/{warehouse}/namespaces/{namespace}/tables/{table}", "/{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/tables"));
assert_route(&router, Method::POST, &table_catalog_path("/analytics/namespaces/sales/register")); 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"));
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::POST, &table_catalog_path("/analytics/namespaces/sales/tables/orders"));
assert_route(&router, Method::DELETE, &table_catalog_path("/analytics/namespaces/sales/tables/orders")); assert_route(&router, Method::DELETE, &table_catalog_path("/analytics/namespaces/sales/tables/orders"));
assert_route( assert_route(
@@ -654,6 +669,11 @@ fn test_register_routes_cover_representative_admin_paths() {
Method::GET, Method::GET,
&compat_table_catalog_path("/analytics/namespaces/sales/tables/orders"), &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( assert_route(
&router, &router,
Method::POST, Method::POST,
+15 -1
View File
@@ -112,13 +112,27 @@ added.
Unsupported behavior is documented instead of hidden behind internal errors. The Unsupported behavior is documented instead of hidden behind internal errors. The
current unsupported inventory is: 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 - background maintenance worker: unsupported
- manifest/data reachability cleanup: unsupported - manifest/data reachability cleanup: unsupported
- snapshot expiration and compaction: unsupported - snapshot expiration and compaction: unsupported
- Iceberg views: unsupported - Iceberg views: unsupported
- multi-table transactions: not a short-term production claim - 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 Manual Baseline
Spark validation should use the same RustFS endpoint and warehouse bucket as the 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]] = [ UNSUPPORTED_INVENTORY: list[dict[str, str]] = [
{ {
"capability": "credential-vending", "capability": "credential-vending",
"status": "unsupported-response-boundary", "status": "scope-preview-no-temporary-credentials",
"roadmap_area": "credential-boundary", "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", "capability": "background-maintenance-worker",
@@ -4,13 +4,19 @@
from __future__ import annotations from __future__ import annotations
import os import os
import re
import sys import sys
import unittest import unittest
from pathlib import Path
from unittest import mock from unittest import mock
import pyiceberg_smoke 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): class PyIcebergSmokeConfigTest(unittest.TestCase):
def parse_with_args(self, argv: list[str]) -> object: def parse_with_args(self, argv: list[str]) -> object:
env_keys = [ env_keys = [
@@ -70,6 +76,12 @@ class PyIcebergSmokeConfigTest(unittest.TestCase):
self.assertIn("status", entry) self.assertIn("status", entry)
self.assertIn("roadmap_area", 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__": if __name__ == "__main__":
unittest.main() unittest.main()