fix: honor bucket-scoped ListBucket policies with s3:prefix (#2707)

Co-authored-by: houseme <housemecn@gmail.com>
This commit is contained in:
安正超
2026-04-27 22:13:22 +08:00
committed by GitHub
parent a68fe1601f
commit 159ddd5bac
4 changed files with 293 additions and 25 deletions
+16 -3
View File
@@ -1599,7 +1599,9 @@ mod tests {
use std::time::{Duration, Instant};
// After the last completed response, exit if no new connection arrives within this window.
const IDLE_SHUTDOWN: Duration = Duration::from_millis(500);
// Keep the mock server alive long enough for slower CI/macOS test environments to finish
// discovery + JWKS requests without racing the shutdown timer.
const IDLE_SHUTDOWN: Duration = Duration::from_secs(1);
const ABSOLUTE_CAP: Duration = Duration::from_secs(5);
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
@@ -1701,6 +1703,17 @@ mod tests {
err.contains(base) && err.contains(&format!("{base}/")) && err.contains("discovery failed for all issuer variants")
}
async fn validate_mocked_oidc_provider_config(config: &OidcProviderConfig) -> Result<OidcProviderValidationResult, String> {
let http_client = ReqwestHttpClient::new()?;
let state = OidcSys::discover_provider(config, &http_client).await?;
Ok(OidcProviderValidationResult {
issuer: state.metadata.issuer().to_string(),
authorization_endpoint: state.metadata.authorization_endpoint().to_string(),
token_endpoint: state.metadata.token_endpoint().map(ToString::to_string),
})
}
#[tokio::test]
async fn test_validate_oidc_provider_config_retries_with_issuer_candidates() {
// Discovery document must advertise the canonical issuer path. The first candidate has no
@@ -1709,7 +1722,7 @@ mod tests {
let config_url = format!("{base}/application/o/rustfs");
let config = build_mocked_oidc_provider_config("default", &config_url);
let result = validate_oidc_provider_config(&config).await;
let result = validate_mocked_oidc_provider_config(&config).await;
let validation_result = result.expect("OIDC provider validation should succeed");
assert_eq!(validation_result.issuer, format!("{base}/application/o/rustfs/"));
@@ -1722,7 +1735,7 @@ mod tests {
let config_url = format!("{base}/application/o/rustfs");
let config = build_mocked_oidc_provider_config("default", &config_url);
let err = validate_oidc_provider_config(&config)
let err = validate_mocked_oidc_provider_config(&config)
.await
.expect_err("OIDC provider validation should fail");
assert!(discovery_error_contains_all_variants(&err, &base));
+179
View File
@@ -798,6 +798,139 @@ mod test {
Ok(())
}
#[tokio::test]
async fn test_list_bucket_prefix_condition_uses_bucket_resource() -> Result<()> {
let policy = Policy::parse_config(
br#"{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:ListBucket"],
"Resource": ["arn:aws:s3:::polaris-test-bucket"],
"Condition": {
"StringLike": {
"s3:prefix": [
"polaris_test/snowflake_catalog/db1/schema/iceberg_table/*"
]
}
}
}
]
}"#,
)?;
let mut conditions = HashMap::new();
conditions.insert(
"prefix".to_string(),
vec!["polaris_test/snowflake_catalog/db1/schema/iceberg_table/metadata/".to_string()],
);
let claims = HashMap::new();
let args = Args {
account: "polaris-session",
groups: &None,
action: Action::S3Action(crate::policy::action::S3Action::ListBucketAction),
bucket: "polaris-test-bucket",
conditions: &conditions,
is_owner: false,
object: "polaris_test/snowflake_catalog/db1/schema/iceberg_table/metadata/",
claims: &claims,
deny_only: false,
};
assert!(
policy.is_allowed(&args).await,
"ListBucket should match the bucket resource and apply the prefix through the condition, not by converting the prefix into an object resource"
);
Ok(())
}
#[tokio::test]
async fn test_list_bucket_versions_prefix_condition_uses_bucket_resource() -> Result<()> {
let policy = Policy::parse_config(
br#"{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:ListBucketVersions"],
"Resource": ["arn:aws:s3:::polaris-test-bucket"],
"Condition": {
"StringLike": {
"s3:prefix": [
"polaris_test/snowflake_catalog/db1/schema/iceberg_table/*"
]
}
}
}
]
}"#,
)?;
let mut conditions = HashMap::new();
conditions.insert(
"prefix".to_string(),
vec!["polaris_test/snowflake_catalog/db1/schema/iceberg_table/metadata/".to_string()],
);
let claims = HashMap::new();
let args = Args {
account: "polaris-session",
groups: &None,
action: Action::S3Action(crate::policy::action::S3Action::ListBucketVersionsAction),
bucket: "polaris-test-bucket",
conditions: &conditions,
is_owner: false,
object: "polaris_test/snowflake_catalog/db1/schema/iceberg_table/metadata/",
claims: &claims,
deny_only: false,
};
assert!(
policy.is_allowed(&args).await,
"ListBucketVersions should match the bucket resource and apply the prefix through the condition"
);
Ok(())
}
#[tokio::test]
async fn test_list_bucket_gateway_prefix_uses_object_resource_when_condition_missing() -> Result<()> {
let policy = Policy::parse_config(
br#"{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:ListBucket"],
"Resource": ["arn:aws:s3:::polaris-test-bucket/home/alice/*"]
}
]
}"#,
)?;
let conditions = HashMap::new();
let claims = HashMap::new();
let args = Args {
account: "polaris-session",
groups: &None,
action: Action::S3Action(crate::policy::action::S3Action::ListBucketAction),
bucket: "polaris-test-bucket",
conditions: &conditions,
is_owner: false,
object: "home/alice/projects/",
claims: &claims,
deny_only: false,
};
assert!(
policy.is_allowed(&args).await,
"Gateway ListBucket auth without an s3:prefix condition should continue matching prefix-scoped resources via args.object"
);
Ok(())
}
#[tokio::test]
async fn test_aws_username_policy_variable() -> Result<()> {
let data = r#"
@@ -1408,6 +1541,52 @@ mod test {
assert_eq!(arr[0].as_str().unwrap(), "s3:ListBucket");
}
#[tokio::test]
async fn test_bucket_policy_list_bucket_prefix_condition_uses_bucket_resource() -> Result<()> {
let bucket_policy: BucketPolicy = serde_json::from_str(
r#"{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {"AWS": "*"},
"Action": ["s3:ListBucket"],
"Resource": ["arn:aws:s3:::polaris-test-bucket"],
"Condition": {
"StringLike": {
"s3:prefix": [
"polaris_test/snowflake_catalog/db1/schema/iceberg_table/*"
]
}
}
}
]
}"#,
)?;
let mut conditions = HashMap::new();
conditions.insert(
"prefix".to_string(),
vec!["polaris_test/snowflake_catalog/db1/schema/iceberg_table/metadata/".to_string()],
);
let args = BucketPolicyArgs {
account: "polaris-session",
groups: &None,
action: Action::S3Action(crate::policy::action::S3Action::ListBucketAction),
bucket: "polaris-test-bucket",
conditions: &conditions,
is_owner: false,
object: "polaris_test/snowflake_catalog/db1/schema/iceberg_table/metadata/",
};
assert!(
bucket_policy.is_allowed(&args).await,
"Bucket policy ListBucket should match the bucket resource and apply the prefix through the condition"
);
Ok(())
}
#[tokio::test]
async fn test_bucket_policy_deny_with_string_not_equals() -> Result<()> {
let data = r#"
+31 -21
View File
@@ -14,7 +14,7 @@
use super::{
ActionSet, Args, BucketPolicyArgs, Effect, Error as IamError, Functions, ID, Principal, ResourceSet, Validator,
action::Action,
action::{Action, S3Action},
variables::{VariableContext, VariableResolver},
};
use crate::error::{Error, Result};
@@ -56,6 +56,34 @@ pub(crate) fn variable_resolver_for_policy_args(args: &Args<'_>) -> VariableReso
VariableResolver::new(context)
}
const LIST_BUCKET_PREFIX_CONDITION_KEY: &str = "prefix";
fn build_resource(
action: &Action,
bucket: &str,
object: &str,
conditions: &std::collections::HashMap<String, Vec<String>>,
) -> String {
let bucket_resource_only = matches!(
action,
Action::S3Action(
S3Action::ListBucketAction | S3Action::ListBucketVersionsAction | S3Action::ListBucketMultipartUploadsAction
)
) && conditions.contains_key(LIST_BUCKET_PREFIX_CONDITION_KEY);
let mut resource = String::from(bucket);
if bucket_resource_only || object.is_empty() {
resource.push('/');
return resource;
}
if !object.starts_with('/') {
resource.push('/');
}
resource.push_str(object);
resource
}
impl Statement {
fn is_kms(&self) -> bool {
for act in self.actions.iter() {
@@ -94,16 +122,7 @@ impl Statement {
return false;
}
let mut resource = String::from(args.bucket);
if !args.object.is_empty() {
if !args.object.starts_with('/') {
resource.push('/');
}
resource.push_str(args.object);
} else {
resource.push('/');
}
let resource = build_resource(&args.action, args.bucket, args.object, args.conditions);
if self.is_kms() && (resource == "/" || self.resources.is_empty()) {
return true;
@@ -230,16 +249,7 @@ impl BPStatement {
return false;
}
let mut resource = String::from(args.bucket);
if !args.object.is_empty() {
if !args.object.starts_with('/') {
resource.push('/');
}
resource.push_str(args.object);
} else {
resource.push('/');
}
let resource = build_resource(&args.action, args.bucket, args.object, args.conditions);
if !self.resources.is_empty() && !self.resources.is_match(&resource, args.conditions).await {
return false;