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
+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;