feat(table-catalog): issue scoped table credentials (#3413)

* feat(table-catalog): issue scoped table credentials

* fix(table-catalog): harden credential vending defaults

---------

Co-authored-by: Henry Guo <marshawcoco@users.noreply.github.com>
Co-authored-by: houseme <housemecn@gmail.com>
This commit is contained in:
Henry Guo
2026-06-14 02:20:28 +08:00
committed by GitHub
parent 7da10db852
commit 357287d626
3 changed files with 501 additions and 43 deletions
+480 -34
View File
@@ -24,14 +24,26 @@ use hyper::Method;
use matchit::Params;
use rustfs_config::MAX_ADMIN_REQUEST_BODY_SIZE;
use rustfs_ecstore::{bucket::metadata_sys, new_object_layer_fn, store::ECStore};
use rustfs_policy::policy::action::{Action, AdminAction};
use rustfs_iam::{manager::get_token_signing_key, sys::SESSION_POLICY_NAME};
use rustfs_policy::{
auth::get_new_credentials_with_metadata,
policy::{
Policy,
action::{Action, AdminAction},
},
};
use s3s::{Body, S3Request, S3Response, S3Result, header::CONTENT_TYPE, s3_error};
use serde::{Deserialize, Serialize, de::DeserializeOwned};
use std::collections::BTreeMap;
use time::OffsetDateTime;
use std::collections::{BTreeMap, HashMap};
use time::{Duration, OffsetDateTime};
use uuid::Uuid;
const JSON_CONTENT_TYPE: &str = "application/json";
const ENV_TABLE_CATALOG_CREDENTIAL_VENDING: &str = "RUSTFS_TABLE_CATALOG_CREDENTIAL_VENDING";
const ENV_TABLE_CATALOG_CREDENTIAL_TTL_SECONDS: &str = "RUSTFS_TABLE_CATALOG_CREDENTIAL_TTL_SECONDS";
const DEFAULT_TABLE_CATALOG_CREDENTIAL_TTL_SECONDS: i64 = 15 * 60;
const MIN_TABLE_CATALOG_CREDENTIAL_TTL_SECONDS: i64 = 60;
const MAX_TABLE_CATALOG_CREDENTIAL_TTL_SECONDS: i64 = 60 * 60;
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";
@@ -40,11 +52,17 @@ const CREDENTIAL_VENDING_REASON_CONFIG_KEY: &str = "rustfs.credential-vending-re
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_EXPIRATION_CONFIG_KEY: &str = "rustfs.credential-expiration-unix-seconds";
const CREDENTIAL_VENDING_UNSUPPORTED: &str = "unsupported";
const CREDENTIAL_VENDING_SUPPORTED: &str = "supported";
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 CREDENTIAL_MODE_CATALOG_VENDED: &str = "catalog-vended-temporary-credentials";
const S3_ACCESS_KEY_ID_CONFIG_KEY: &str = "s3.access-key-id";
const S3_SECRET_ACCESS_KEY_CONFIG_KEY: &str = "s3.secret-access-key";
const S3_SESSION_TOKEN_CONFIG_KEY: &str = "s3.session-token";
const TABLE_CATALOG_NAMESPACE_RESOURCE_ROOT: &str = "namespaces";
const TABLE_CATALOG_TABLE_RESOURCE_ROOT: &str = "tables";
const TABLE_CATALOG_ENDPOINTS: &[&str] = &[
@@ -279,6 +297,142 @@ struct RestStorageCredential {
config: BTreeMap<String, String>,
}
#[derive(Debug, Clone)]
struct TableCredentialScope {
scope_prefix: String,
object_prefix: String,
}
#[derive(Debug, Clone)]
struct TableCredentialIssueRequest<'a> {
entry: &'a crate::table_catalog::TableEntry,
principal: Option<&'a rustfs_credentials::Credentials>,
scope_prefix: String,
object_prefix: String,
}
#[derive(Debug, Clone)]
struct IssuedTableCredentials {
access_key_id: String,
secret_access_key: String,
session_token: String,
expiration: OffsetDateTime,
}
#[async_trait::async_trait]
trait TableCredentialIssuer: Sync {
fn enabled(&self) -> bool {
true
}
async fn issue_table_credentials(&self, request: TableCredentialIssueRequest<'_>)
-> S3Result<Option<IssuedTableCredentials>>;
}
#[cfg(test)]
struct DisabledTableCredentialIssuer;
#[cfg(test)]
#[async_trait::async_trait]
impl TableCredentialIssuer for DisabledTableCredentialIssuer {
fn enabled(&self) -> bool {
false
}
async fn issue_table_credentials(
&self,
_request: TableCredentialIssueRequest<'_>,
) -> S3Result<Option<IssuedTableCredentials>> {
Ok(None)
}
}
struct IamTableCredentialIssuer {
enabled: bool,
ttl_seconds: i64,
}
impl IamTableCredentialIssuer {
fn from_env() -> Self {
Self {
enabled: table_credential_vending_enabled(),
ttl_seconds: table_credential_ttl_seconds(),
}
}
}
#[async_trait::async_trait]
impl TableCredentialIssuer for IamTableCredentialIssuer {
fn enabled(&self) -> bool {
self.enabled
}
async fn issue_table_credentials(
&self,
request: TableCredentialIssueRequest<'_>,
) -> S3Result<Option<IssuedTableCredentials>> {
if !self.enabled {
return Ok(None);
}
let Some(principal) = request.principal else {
return Err(s3_error!(InvalidRequest, "authentication required for table credentials"));
};
if principal.is_temp() || principal.is_service_account() {
return Err(s3_error!(
AccessDenied,
"table credential vending does not allow chained temporary credentials"
));
}
let policy = table_credential_session_policy(&request.entry.table_bucket, &request.object_prefix)?;
let policy_buf = serde_json::to_vec(&policy)
.map_err(|err| s3_error!(InternalError, "failed to serialize table credential session policy: {}", err))?;
let expiration = OffsetDateTime::now_utc().saturating_add(Duration::seconds(self.ttl_seconds));
let mut claims: HashMap<String, serde_json::Value> = principal.claims.clone().unwrap_or_default();
claims.insert(
"exp".to_string(),
serde_json::Value::Number(serde_json::Number::from(expiration.unix_timestamp())),
);
claims.insert("parent".to_string(), serde_json::Value::String(principal.access_key.clone()));
claims.insert(
SESSION_POLICY_NAME.to_string(),
serde_json::Value::String(base64_simd::URL_SAFE_NO_PAD.encode_to_string(&policy_buf)),
);
claims.insert(
"rustfs:table-bucket".to_string(),
serde_json::Value::String(request.entry.table_bucket.clone()),
);
claims.insert("rustfs:table-id".to_string(), serde_json::Value::String(request.entry.table_id.clone()));
claims.insert(
"rustfs:credential-scope-prefix".to_string(),
serde_json::Value::String(request.scope_prefix.clone()),
);
let secret = get_token_signing_key().ok_or_else(|| s3_error!(InternalError, "token signing key not initialized"))?;
let mut credential = get_new_credentials_with_metadata(&claims, &secret)
.map_err(|err| s3_error!(InternalError, "failed to generate table credentials: {}", err))?;
bind_table_credential_parent(&mut credential, principal);
let iam_store = rustfs_iam::get().map_err(|_| s3_error!(InternalError, "iam not init"))?;
iam_store
.set_temp_user(&credential.access_key, &credential, None)
.await
.map_err(|_| s3_error!(InternalError, "failed to store table credentials"))?;
Ok(Some(IssuedTableCredentials {
access_key_id: credential.access_key,
secret_access_key: credential.secret_key,
session_token: credential.session_token,
expiration,
}))
}
}
fn bind_table_credential_parent(credential: &mut rustfs_credentials::Credentials, principal: &rustfs_credentials::Credentials) {
credential.parent_user = principal.access_key.clone();
}
#[derive(Debug, Serialize)]
struct RestLoadTableResponse {
#[serde(rename = "metadata-location")]
@@ -575,6 +729,15 @@ async fn authorize_table_catalog_resource_request(
.await
}
async fn table_catalog_request_principal(req: &S3Request<Body>) -> S3Result<rustfs_credentials::Credentials> {
let Some(input_cred) = &req.credentials else {
return Err(s3_error!(InvalidRequest, "authentication required"));
};
let (cred, _owner) =
check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?;
Ok(cred)
}
async fn read_json_body<T: DeserializeOwned>(mut input: Body) -> S3Result<T> {
let body = input
.store_all_limited(MAX_ADMIN_REQUEST_BODY_SIZE)
@@ -764,6 +927,118 @@ fn list_tables_response_from_entries(entries: Vec<crate::table_catalog::TableEnt
Ok(RestListTablesResponse { identifiers })
}
fn table_credential_vending_enabled() -> bool {
std::env::var(ENV_TABLE_CATALOG_CREDENTIAL_VENDING)
.ok()
.map(|value| matches!(value.to_ascii_lowercase().as_str(), "1" | "true" | "on" | "enabled"))
.unwrap_or(false)
}
fn table_credential_ttl_seconds() -> i64 {
std::env::var(ENV_TABLE_CATALOG_CREDENTIAL_TTL_SECONDS)
.ok()
.and_then(|value| value.parse::<i64>().ok())
.map(|seconds| seconds.clamp(MIN_TABLE_CATALOG_CREDENTIAL_TTL_SECONDS, MAX_TABLE_CATALOG_CREDENTIAL_TTL_SECONDS))
.unwrap_or(DEFAULT_TABLE_CATALOG_CREDENTIAL_TTL_SECONDS)
}
fn table_credential_scope(entry: &crate::table_catalog::TableEntry) -> S3Result<TableCredentialScope> {
let location = entry
.warehouse_location
.strip_prefix("s3://")
.ok_or_else(|| s3_error!(InvalidRequest, "table warehouse location must be an s3 URI"))?;
let (bucket, object_prefix) = location
.split_once('/')
.ok_or_else(|| s3_error!(InvalidRequest, "table warehouse location must include an object prefix"))?;
if bucket != entry.table_bucket {
return Err(s3_error!(InvalidRequest, "table warehouse location must be inside the table bucket"));
}
let object_prefix = normalize_table_credential_object_prefix(object_prefix)?;
Ok(TableCredentialScope {
scope_prefix: format!("s3://{bucket}/{object_prefix}"),
object_prefix,
})
}
fn normalize_table_credential_object_prefix(object_prefix: &str) -> S3Result<String> {
let object_prefix = object_prefix.strip_suffix('/').unwrap_or(object_prefix);
if object_prefix.is_empty() {
return Err(s3_error!(InvalidRequest, "table credential scope prefix is empty"));
}
if object_prefix.contains('\\') {
return Err(s3_error!(
InvalidRequest,
"table credential scope prefix contains an invalid path separator"
));
}
if object_prefix
.split('/')
.any(|segment| segment.is_empty() || segment == "." || segment == "..")
{
return Err(s3_error!(
InvalidRequest,
"table credential scope prefix contains an invalid path segment"
));
}
let mut normalized = object_prefix.to_string();
normalized.push('/');
Ok(normalized)
}
fn table_credential_session_policy(bucket: &str, object_prefix: &str) -> S3Result<Policy> {
let object_prefix = normalize_table_credential_object_prefix(object_prefix)?;
let policy = serde_json::json!({
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"s3:GetObject",
"s3:PutObject",
"s3:DeleteObject",
"s3:AbortMultipartUpload",
"s3:ListMultipartUploadParts"
],
"Resource": [
format!("arn:aws:s3:::{bucket}/{object_prefix}*")
]
},
{
"Effect": "Allow",
"Action": [
"s3:GetBucketLocation"
],
"Resource": [
format!("arn:aws:s3:::{bucket}")
]
}
]
});
let data = serde_json::to_vec(&policy)
.map_err(|err| s3_error!(InternalError, "failed to serialize table credential policy: {}", err))?;
Policy::parse_config(&data).map_err(|err| s3_error!(InvalidRequest, "invalid table credential policy: {}", err))
}
fn storage_credential_from_issued(scope: TableCredentialScope, issued: IssuedTableCredentials) -> RestStorageCredential {
let mut config = BTreeMap::new();
config.insert(S3_ACCESS_KEY_ID_CONFIG_KEY.to_string(), issued.access_key_id);
config.insert(S3_SECRET_ACCESS_KEY_CONFIG_KEY.to_string(), issued.secret_access_key);
config.insert(S3_SESSION_TOKEN_CONFIG_KEY.to_string(), issued.session_token);
config.insert(CREDENTIAL_VENDING_CONFIG_KEY.to_string(), CREDENTIAL_VENDING_SUPPORTED.to_string());
config.insert(CREDENTIAL_MODE_CONFIG_KEY.to_string(), CREDENTIAL_MODE_CATALOG_VENDED.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(), scope.scope_prefix.clone());
config.insert(
CREDENTIAL_EXPIRATION_CONFIG_KEY.to_string(),
issued.expiration.unix_timestamp().to_string(),
);
RestStorageCredential {
prefix: scope.scope_prefix,
config,
}
}
fn load_table_response_from_entry(entry: crate::table_catalog::TableEntry, metadata: serde_json::Value) -> RestLoadTableResponse {
let mut config = BTreeMap::new();
let warehouse_location = entry.warehouse_location.clone();
@@ -785,10 +1060,28 @@ 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(),
async fn load_credentials_response_from_entry(
entry: &crate::table_catalog::TableEntry,
issuer: &dyn TableCredentialIssuer,
principal: Option<&rustfs_credentials::Credentials>,
) -> S3Result<RestLoadCredentialsResponse> {
if !issuer.enabled() {
return Ok(RestLoadCredentialsResponse {
storage_credentials: Vec::new(),
});
}
let scope = table_credential_scope(entry)?;
let request = TableCredentialIssueRequest {
entry,
principal,
scope_prefix: scope.scope_prefix.clone(),
object_prefix: scope.object_prefix.clone(),
};
let storage_credentials = match issuer.issue_table_credentials(request).await? {
Some(issued) => vec![storage_credential_from_issued(scope, issued)],
None => Vec::new(),
};
Ok(RestLoadCredentialsResponse { storage_credentials })
}
fn commit_table_response_from_result(
@@ -1866,6 +2159,8 @@ async fn load_credentials_response<S>(
bucket: &str,
namespace: &crate::table_catalog::Namespace,
table: &str,
issuer: &dyn TableCredentialIssuer,
principal: Option<&rustfs_credentials::Credentials>,
) -> S3Result<RestLoadCredentialsResponse>
where
S: crate::table_catalog::TableCatalogStore + ?Sized,
@@ -1877,7 +2172,7 @@ where
else {
return Err(s3_error!(InvalidRequest, "table not found"));
};
Ok(load_credentials_response_from_entry(entry))
load_credentials_response_from_entry(&entry, issuer, principal).await
}
async fn get_table_metadata_location_response<S>(
@@ -2356,8 +2651,10 @@ impl Operation for RestLoadCredentialsHandler {
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 principal = table_catalog_request_principal(&req).await?;
let store = table_catalog_store()?;
let response = load_credentials_response(&store, &warehouse, &namespace, &table).await?;
let issuer = IamTableCredentialIssuer::from_env();
let response = load_credentials_response(&store, &warehouse, &namespace, &table, &issuer, Some(&principal)).await?;
build_json_response(StatusCode::OK, &response)
}
}
@@ -3679,28 +3976,7 @@ mod tests {
"table-uuid": "table-uuid",
"location": "s3://warehouse/tables/table-id"
});
let response = load_table_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,
},
metadata.clone(),
);
let response = load_table_response_from_entry(table_entry_for_credentials(), metadata.clone());
assert_eq!(response.metadata, metadata);
assert!(response.storage_credentials.is_empty());
@@ -3723,9 +3999,8 @@ mod tests {
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 {
fn table_entry_for_credentials() -> crate::table_catalog::TableEntry {
crate::table_catalog::TableEntry {
version: crate::table_catalog::TABLE_CATALOG_ENTRY_VERSION,
table_bucket: "warehouse".to_string(),
namespace: "analytics".to_string(),
@@ -3743,11 +4018,182 @@ mod tests {
properties: BTreeMap::new(),
created_at: None,
updated_at: None,
});
}
}
#[tokio::test]
async fn disabled_table_credential_issuer_keeps_credentials_empty() {
let issuer = DisabledTableCredentialIssuer;
let response = load_credentials_response_from_entry(&table_entry_for_credentials(), &issuer, None)
.await
.expect("disabled issuer should build an empty response");
assert!(response.storage_credentials.is_empty());
}
#[tokio::test]
async fn disabled_table_credential_issuer_skips_scope_validation() {
let issuer = DisabledTableCredentialIssuer;
let mut entry = table_entry_for_credentials();
entry.warehouse_location = "s3://warehouse/".to_string();
let response = load_credentials_response_from_entry(&entry, &issuer, None)
.await
.expect("disabled issuer should not validate credential scopes");
assert!(response.storage_credentials.is_empty());
}
struct TestTableCredentialIssuer;
#[async_trait::async_trait]
impl TableCredentialIssuer for TestTableCredentialIssuer {
async fn issue_table_credentials(
&self,
request: TableCredentialIssueRequest<'_>,
) -> S3Result<Option<IssuedTableCredentials>> {
assert_eq!(request.entry.table_bucket, "warehouse");
assert_eq!(request.scope_prefix, "s3://warehouse/tables/table-id/");
assert_eq!(request.object_prefix, "tables/table-id/");
Ok(Some(IssuedTableCredentials {
access_key_id: "temporary-access-key".to_string(),
secret_access_key: "temporary-secret-key".to_string(),
session_token: "temporary-session-token".to_string(),
expiration: OffsetDateTime::from_unix_timestamp(1_800_000_000).expect("test timestamp should be valid"),
}))
}
}
#[tokio::test]
async fn credential_issuer_returns_temporary_scoped_storage_credentials() {
let issuer = TestTableCredentialIssuer;
let principal = rustfs_credentials::Credentials {
access_key: "parent-access-key".to_string(),
secret_key: "parent-secret-key".to_string(),
..Default::default()
};
let response = load_credentials_response_from_entry(&table_entry_for_credentials(), &issuer, Some(&principal))
.await
.expect("issuer should build a scoped credential response");
assert_eq!(response.storage_credentials.len(), 1);
let credential = &response.storage_credentials[0];
assert_eq!(credential.prefix, "s3://warehouse/tables/table-id/");
assert_eq!(credential.config.get("s3.access-key-id"), Some(&"temporary-access-key".to_string()));
assert_eq!(credential.config.get("s3.secret-access-key"), Some(&"temporary-secret-key".to_string()));
assert_eq!(credential.config.get("s3.session-token"), Some(&"temporary-session-token".to_string()));
assert_eq!(
credential.config.get("rustfs.credential-mode"),
Some(&"catalog-vended-temporary-credentials".to_string())
);
assert_eq!(
credential.config.get("rustfs.credential-scope-prefix"),
Some(&"s3://warehouse/tables/table-id/".to_string())
);
assert_eq!(
credential.config.get("rustfs.credential-expiration-unix-seconds"),
Some(&"1800000000".to_string())
);
assert!(!credential.config.contains_key("rustfs.credential-vending-reason"));
}
#[test]
fn table_credentials_do_not_snapshot_parent_groups() {
let principal = rustfs_credentials::Credentials {
access_key: "parent-access-key".to_string(),
groups: Some(vec!["analytics-writers".to_string()]),
..Default::default()
};
let mut credential = rustfs_credentials::Credentials::default();
bind_table_credential_parent(&mut credential, &principal);
assert_eq!(credential.parent_user, "parent-access-key");
assert!(credential.groups.is_none());
}
#[tokio::test]
async fn table_credential_session_policy_is_limited_to_table_prefix() {
let policy =
table_credential_session_policy("warehouse", "tables/table-id/").expect("table credential policy should build");
let groups = None;
let conditions = std::collections::HashMap::new();
let claims = std::collections::HashMap::new();
assert!(
policy
.is_allowed(&rustfs_policy::policy::Args {
account: "temporary-access-key",
groups: &groups,
action: Action::S3Action(rustfs_policy::policy::action::S3Action::GetObjectAction),
bucket: "warehouse",
conditions: &conditions,
is_owner: false,
object: "tables/table-id/data/file.parquet",
claims: &claims,
deny_only: false,
})
.await
);
assert!(
policy
.is_allowed(&rustfs_policy::policy::Args {
account: "temporary-access-key",
groups: &groups,
action: Action::S3Action(rustfs_policy::policy::action::S3Action::GetBucketLocationAction),
bucket: "warehouse",
conditions: &conditions,
is_owner: false,
object: "",
claims: &claims,
deny_only: false,
})
.await
);
assert!(
!policy
.is_allowed(&rustfs_policy::policy::Args {
account: "temporary-access-key",
groups: &groups,
action: Action::S3Action(rustfs_policy::policy::action::S3Action::GetObjectAction),
bucket: "warehouse",
conditions: &conditions,
is_owner: false,
object: "tables/other/data/file.parquet",
claims: &claims,
deny_only: false,
})
.await
);
assert!(
!policy
.is_allowed(&rustfs_policy::policy::Args {
account: "temporary-access-key",
groups: &groups,
action: Action::S3Action(rustfs_policy::policy::action::S3Action::PutObjectAction),
bucket: "other-warehouse",
conditions: &conditions,
is_owner: false,
object: "tables/table-id/data/file.parquet",
claims: &claims,
deny_only: false,
})
.await
);
}
#[test]
fn table_credential_scope_rejects_cross_bucket_or_unsafe_prefix() {
let mut entry = table_entry_for_credentials();
entry.warehouse_location = "s3://other-warehouse/tables/table-id".to_string();
assert!(table_credential_scope(&entry).is_err());
let mut entry = table_entry_for_credentials();
entry.warehouse_location = "s3://warehouse/tables/../table-id".to_string();
assert!(table_credential_scope(&entry).is_err());
}
#[test]
fn commit_table_request_uses_rest_commit_fields() {
let request: RestCommitTableRequest = serde_json::from_value(serde_json::json!({
+18 -6
View File
@@ -112,7 +112,7 @@ added.
Unsupported behavior is documented instead of hidden behind internal errors. The
current unsupported inventory is:
- credential vending: non-secret table scope preview and credentials endpoint exist; real temporary credentials are not issued
- credential vending: table scope preview and credentials endpoint exist; temporary credentials are available only when explicitly enabled and are not yet covered by automated client profiles
- background maintenance worker: unsupported
- manifest/data reachability cleanup: unsupported
- snapshot expiration and compaction: unsupported
@@ -122,16 +122,28 @@ current unsupported inventory is:
## 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:
storage secrets by default. `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.
The endpoint returns an empty `storage-credentials` list unless table catalog
credential vending is explicitly enabled. When enabled, RustFS issues temporary
table-scoped S3 credentials through the credentials endpoint. Those credentials
are constrained to the table warehouse prefix and include a session token and
expiration. The automated smoke profiles still use configured S3 credentials for
object data access until a catalog-vended credential profile is added.
Enablement is server-side and fail-closed:
```text
RUSTFS_TABLE_CATALOG_CREDENTIAL_VENDING=enabled
RUSTFS_TABLE_CATALOG_CREDENTIAL_TTL_SECONDS=900
```
The TTL is clamped to the supported short-lived range by the server.
## Spark Manual Baseline
+3 -3
View File
@@ -111,10 +111,10 @@ CLIENT_MATRIX: list[dict[str, str]] = [
UNSUPPORTED_INVENTORY: list[dict[str, str]] = [
{
"capability": "credential-vending",
"status": "scope-preview-no-temporary-credentials",
"roadmap_area": "credential-boundary",
"status": "manual-enable-temporary-credentials-no-client-profile",
"roadmap_area": "credential-vending",
"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",
"expected_behavior": "load table advertises the table credential scope; the credentials endpoint returns an empty storage-credentials list unless server-side temporary credential vending is explicitly enabled",
},
{
"capability": "background-maintenance-worker",