feat: Implement AWS policy variables support (#1131)

Co-authored-by: houseme <housemecn@gmail.com>
Co-authored-by: loverustfs <hello@rustfs.com>
This commit is contained in:
yxrxy
2025-12-16 13:32:01 +08:00
committed by GitHub
parent fe4fabb195
commit 352035a06f
18 changed files with 2169 additions and 50 deletions

52
Cargo.lock generated
View File

@@ -63,6 +63,17 @@ dependencies = [
"subtle",
]
[[package]]
name = "ahash"
version = "0.7.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9"
dependencies = [
"getrandom 0.2.16",
"once_cell",
"version_check",
]
[[package]]
name = "ahash"
version = "0.8.12"
@@ -286,7 +297,7 @@ version = "57.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a23eaff85a44e9fa914660fb0d0bb00b79c4a3d888b5334adb3ea4330c84f002"
dependencies = [
"ahash",
"ahash 0.8.12",
"arrow-buffer",
"arrow-data",
"arrow-schema",
@@ -443,7 +454,7 @@ version = "57.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae980d021879ea119dd6e2a13912d81e64abed372d53163e804dfe84639d8010"
dependencies = [
"ahash",
"ahash 0.8.12",
"arrow-array",
"arrow-buffer",
"arrow-data",
@@ -716,7 +727,7 @@ dependencies = [
"http 0.2.12",
"http 1.4.0",
"http-body 0.4.6",
"lru",
"lru 0.12.5",
"percent-encoding",
"regex-lite",
"sha2 0.10.9",
@@ -2283,7 +2294,7 @@ version = "51.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c10f7659e96127d25e8366be7c8be4109595d6a2c3eac70421f380a7006a1b0"
dependencies = [
"ahash",
"ahash 0.8.12",
"arrow",
"arrow-ipc",
"chrono",
@@ -2544,7 +2555,7 @@ version = "51.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c25210520a9dcf9c2b2cbbce31ebd4131ef5af7fc60ee92b266dc7d159cb305"
dependencies = [
"ahash",
"ahash 0.8.12",
"arrow",
"datafusion-common",
"datafusion-doc",
@@ -2565,7 +2576,7 @@ version = "51.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62f4a66f3b87300bb70f4124b55434d2ae3fe80455f3574701d0348da040b55d"
dependencies = [
"ahash",
"ahash 0.8.12",
"arrow",
"datafusion-common",
"datafusion-expr-common",
@@ -2676,7 +2687,7 @@ version = "51.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c30cc8012e9eedcb48bbe112c6eff4ae5ed19cf3003cb0f505662e88b7014c5d"
dependencies = [
"ahash",
"ahash 0.8.12",
"arrow",
"datafusion-common",
"datafusion-expr",
@@ -2713,7 +2724,7 @@ version = "51.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90da43e1ec550b172f34c87ec68161986ced70fd05c8d2a2add66eef9c276f03"
dependencies = [
"ahash",
"ahash 0.8.12",
"arrow",
"datafusion-common",
"datafusion-expr-common",
@@ -2746,7 +2757,7 @@ version = "51.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0acf0ad6b6924c6b1aa7d213b181e012e2d3ec0a64ff5b10ee6282ab0f8532ac"
dependencies = [
"ahash",
"ahash 0.8.12",
"arrow",
"arrow-ord",
"arrow-schema",
@@ -3945,6 +3956,9 @@ name = "hashbrown"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
dependencies = [
"ahash 0.7.8",
]
[[package]]
name = "hashbrown"
@@ -3952,7 +3966,7 @@ version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
dependencies = [
"ahash",
"ahash 0.8.12",
"allocator-api2",
]
@@ -4462,7 +4476,7 @@ version = "0.11.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "232929e1d75fe899576a3d5c7416ad0d88dbfbb3c3d6aa00873a7408a50ddb88"
dependencies = [
"ahash",
"ahash 0.8.12",
"indexmap 2.12.1",
"is-terminal",
"itoa",
@@ -4480,7 +4494,7 @@ version = "0.12.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d35223c50fdd26419a4ccea2c73be68bd2b29a3d7d6123ffe101c17f4c20a52a"
dependencies = [
"ahash",
"ahash 0.8.12",
"clap",
"crossbeam-channel",
"crossbeam-utils",
@@ -4894,6 +4908,15 @@ dependencies = [
"value-bag",
]
[[package]]
name = "lru"
version = "0.7.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e999beba7b6e8345721bd280141ed958096a2e4abdf74f67ff4ce49b4b54e47a"
dependencies = [
"hashbrown 0.12.3",
]
[[package]]
name = "lru"
version = "0.12.5"
@@ -5048,7 +5071,7 @@ version = "0.24.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d5312e9ba3771cfa961b585728215e3d972c950a3eed9252aa093d6301277e8"
dependencies = [
"ahash",
"ahash 0.8.12",
"portable-atomic",
]
@@ -5710,7 +5733,7 @@ version = "57.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be3e4f6d320dd92bfa7d612e265d7d08bba0a240bab86af3425e1d255a511d89"
dependencies = [
"ahash",
"ahash 0.8.12",
"arrow-array",
"arrow-buffer",
"arrow-cast",
@@ -7465,6 +7488,7 @@ dependencies = [
"chrono",
"ipnetwork",
"jsonwebtoken",
"lru 0.7.8",
"rand 0.10.0-rc.5",
"regex",
"reqwest",

View File

@@ -251,6 +251,7 @@ walkdir = "2.5.0"
wildmatch = { version = "2.6.1", features = ["serde"] }
winapi = { version = "0.3.9" }
xxhash-rust = { version = "0.8.15", features = ["xxh64", "xxh3"] }
lru = "0.7.1"
zip = "6.0.0"
zstd = "0.13.3"

View File

@@ -327,7 +327,8 @@ pub async fn execute_awscurl(
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("awscurl failed: {stderr}").into());
let stdout = String::from_utf8_lossy(&output.stdout);
return Err(format!("awscurl failed: stderr='{stderr}', stdout='{stdout}'").into());
}
let response = String::from_utf8_lossy(&output.stdout).to_string();
@@ -352,3 +353,13 @@ pub async fn awscurl_get(
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
execute_awscurl(url, "GET", None, access_key, secret_key).await
}
/// Helper function for PUT requests
pub async fn awscurl_put(
url: &str,
body: &str,
access_key: &str,
secret_key: &str,
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
execute_awscurl(url, "PUT", Some(body), access_key, secret_key).await
}

View File

@@ -33,3 +33,7 @@ mod special_chars_test;
// Content-Encoding header preservation test
#[cfg(test)]
mod content_encoding_test;
// Policy variables tests
#[cfg(test)]
mod policy;

View File

@@ -0,0 +1,39 @@
# RustFS Policy Variables Tests
This directory contains comprehensive end-to-end tests for AWS IAM policy variables in RustFS.
## Test Overview
The tests cover the following AWS policy variable scenarios:
1. **Single-value variables** - Basic variable resolution like `${aws:username}`
2. **Multi-value variables** - Variables that can have multiple values
3. **Variable concatenation** - Combining variables with static text like `prefix-${aws:username}-suffix`
4. **Nested variables** - Complex nested variable patterns like `${${aws:username}-test}`
5. **Deny scenarios** - Testing deny policies with variables
## Prerequisites
- RustFS server binary
- `awscurl` utility for admin API calls
- AWS SDK for Rust (included in the project)
## Running Tests
### Run All Policy Tests Using Unified Test Runner
```bash
# Run all policy tests with comprehensive reporting
# Note: Requires a RustFS server running on localhost:9000
cargo test -p e2e_test policy::test_runner::test_policy_full_suite -- --nocapture --ignored --test-threads=1
# Run only critical policy tests
cargo test -p e2e_test policy::test_runner::test_policy_critical_suite -- --nocapture --ignored --test-threads=1
```
### Run All Policy Tests
```bash
# From the project root directory
cargo test -p e2e_test policy:: -- --nocapture --ignored --test-threads=1
```

View File

@@ -0,0 +1,22 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Policy-specific tests for RustFS
//!
//! This module provides comprehensive tests for AWS IAM policy variables
//! including single-value, multi-value, and nested variable scenarios.
mod policy_variables_test;
mod test_env;
mod test_runner;

View File

@@ -0,0 +1,798 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Tests for AWS IAM policy variables with single-value, multi-value, and nested scenarios
use crate::common::{awscurl_put, init_logging};
use crate::policy::test_env::PolicyTestEnvironment;
use aws_sdk_s3::primitives::ByteStream;
use serial_test::serial;
use tracing::info;
/// Helper function to create a regular user with given credentials
async fn create_user(
env: &PolicyTestEnvironment,
username: &str,
password: &str,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let create_user_body = serde_json::json!({
"secretKey": password,
"status": "enabled"
})
.to_string();
let create_user_url = format!("{}/rustfs/admin/v3/add-user?accessKey={}", env.url, username);
awscurl_put(&create_user_url, &create_user_body, &env.access_key, &env.secret_key).await?;
Ok(())
}
/// Helper function to create an STS user with given credentials
async fn create_sts_user(
env: &PolicyTestEnvironment,
username: &str,
password: &str,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
// For STS, we create a regular user first, then use it to assume roles
create_user(env, username, password).await?;
Ok(())
}
/// Helper function to create and attach a policy
async fn create_and_attach_policy(
env: &PolicyTestEnvironment,
policy_name: &str,
username: &str,
policy_document: serde_json::Value,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let policy_string = policy_document.to_string();
// Create policy
let add_policy_url = format!("{}/rustfs/admin/v3/add-canned-policy?name={}", env.url, policy_name);
awscurl_put(&add_policy_url, &policy_string, &env.access_key, &env.secret_key).await?;
// Attach policy to user
let attach_policy_url = format!(
"{}/rustfs/admin/v3/set-user-or-group-policy?policyName={}&userOrGroup={}&isGroup=false",
env.url, policy_name, username
);
awscurl_put(&attach_policy_url, "", &env.access_key, &env.secret_key).await?;
Ok(())
}
/// Helper function to clean up test resources
async fn cleanup_user_and_policy(env: &PolicyTestEnvironment, username: &str, policy_name: &str) {
// Create admin client for cleanup
let admin_client = env.create_s3_client(&env.access_key, &env.secret_key);
// Delete buckets that might have been created by this user
let bucket_patterns = [
format!("{username}-test-bucket"),
format!("{username}-bucket1"),
format!("{username}-bucket2"),
format!("{username}-bucket3"),
format!("prefix-{username}-suffix"),
format!("{username}-test"),
format!("{username}-sts-bucket"),
format!("{username}-service-bucket"),
"private-test-bucket".to_string(), // For deny test
];
// Try to delete objects and buckets
for bucket_name in &bucket_patterns {
let _ = admin_client
.delete_object()
.bucket(bucket_name)
.key("test-object.txt")
.send()
.await;
let _ = admin_client
.delete_object()
.bucket(bucket_name)
.key("test-sts-object.txt")
.send()
.await;
let _ = admin_client
.delete_object()
.bucket(bucket_name)
.key("test-service-object.txt")
.send()
.await;
let _ = admin_client.delete_bucket().bucket(bucket_name).send().await;
}
// Remove user
let remove_user_url = format!("{}/rustfs/admin/v3/remove-user?accessKey={}", env.url, username);
let _ = awscurl_put(&remove_user_url, "", &env.access_key, &env.secret_key).await;
// Remove policy
let remove_policy_url = format!("{}/rustfs/admin/v3/remove-canned-policy?name={}", env.url, policy_name);
let _ = awscurl_put(&remove_policy_url, "", &env.access_key, &env.secret_key).await;
}
/// Test AWS policy variables with single-value scenarios
#[tokio::test(flavor = "multi_thread")]
#[serial]
#[ignore = "Starts a rustfs server; enable when running full E2E"]
pub async fn test_aws_policy_variables_single_value() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
test_aws_policy_variables_single_value_impl().await
}
/// Implementation function for single-value policy variables test
pub async fn test_aws_policy_variables_single_value_impl() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
info!("Starting AWS policy variables single-value test");
let env = PolicyTestEnvironment::with_address("127.0.0.1:9000").await?;
test_aws_policy_variables_single_value_impl_with_env(&env).await
}
/// Implementation function for single-value policy variables test with shared environment
pub async fn test_aws_policy_variables_single_value_impl_with_env(
env: &PolicyTestEnvironment,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
// Create test user
let test_user = "testuser1";
let test_password = "testpassword123";
let policy_name = "test-single-value-policy";
// Create cleanup function
let cleanup = || async {
cleanup_user_and_policy(env, test_user, policy_name).await;
};
let create_user_body = serde_json::json!({
"secretKey": test_password,
"status": "enabled"
})
.to_string();
let create_user_url = format!("{}/rustfs/admin/v3/add-user?accessKey={}", env.url, test_user);
awscurl_put(&create_user_url, &create_user_body, &env.access_key, &env.secret_key).await?;
// Create policy with single-value AWS variables
let policy_document = serde_json::json!({
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:ListAllMyBuckets"],
"Resource": ["arn:aws:s3:::*"]
},
{
"Effect": "Allow",
"Action": ["s3:CreateBucket"],
"Resource": [format!("arn:aws:s3:::{}-*", "${aws:username}")]
},
{
"Effect": "Allow",
"Action": ["s3:ListBucket"],
"Resource": [format!("arn:aws:s3:::{}-*", "${aws:username}")]
},
{
"Effect": "Allow",
"Action": ["s3:PutObject", "s3:GetObject"],
"Resource": [format!("arn:aws:s3:::{}-*/*", "${aws:username}")]
}
]
})
.to_string();
let add_policy_url = format!("{}/rustfs/admin/v3/add-canned-policy?name={}", env.url, policy_name);
awscurl_put(&add_policy_url, &policy_document, &env.access_key, &env.secret_key).await?;
// Attach policy to user
let attach_policy_url = format!(
"{}/rustfs/admin/v3/set-user-or-group-policy?policyName={}&userOrGroup={}&isGroup=false",
env.url, policy_name, test_user
);
awscurl_put(&attach_policy_url, "", &env.access_key, &env.secret_key).await?;
// Create S3 client for test user
let test_client = env.create_s3_client(test_user, test_password);
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
// Test 1: User should be able to list buckets (allowed by policy)
info!("Test 1: User listing buckets");
let list_result = test_client.list_buckets().send().await;
if let Err(e) = list_result {
cleanup().await;
return Err(format!("User should be able to list buckets: {e}").into());
}
// Test 2: User should be able to create bucket matching username pattern
info!("Test 2: User creating bucket matching pattern");
let bucket_name = format!("{test_user}-test-bucket");
let create_result = test_client.create_bucket().bucket(&bucket_name).send().await;
if let Err(e) = create_result {
cleanup().await;
return Err(format!("User should be able to create bucket matching username pattern: {e}").into());
}
// Test 3: User should be able to list objects in their own bucket
info!("Test 3: User listing objects in their bucket");
let list_objects_result = test_client.list_objects_v2().bucket(&bucket_name).send().await;
if let Err(e) = list_objects_result {
cleanup().await;
return Err(format!("User should be able to list objects in their own bucket: {e}").into());
}
// Test 4: User should be able to put object in their own bucket
info!("Test 4: User putting object in their bucket");
let put_result = test_client
.put_object()
.bucket(&bucket_name)
.key("test-object.txt")
.body(ByteStream::from_static(b"Hello, Policy Variables!"))
.send()
.await;
if let Err(e) = put_result {
cleanup().await;
return Err(format!("User should be able to put object in their own bucket: {e}").into());
}
// Test 5: User should be able to get object from their own bucket
info!("Test 5: User getting object from their bucket");
let get_result = test_client
.get_object()
.bucket(&bucket_name)
.key("test-object.txt")
.send()
.await;
if let Err(e) = get_result {
cleanup().await;
return Err(format!("User should be able to get object from their own bucket: {e}").into());
}
// Test 6: User should NOT be able to create bucket NOT matching username pattern
info!("Test 6: User attempting to create bucket NOT matching pattern");
let other_bucket_name = "other-user-bucket";
let create_other_result = test_client.create_bucket().bucket(other_bucket_name).send().await;
if create_other_result.is_ok() {
cleanup().await;
return Err("User should NOT be able to create bucket NOT matching username pattern".into());
}
// Cleanup
info!("Cleaning up test resources");
cleanup().await;
info!("AWS policy variables single-value test completed successfully");
Ok(())
}
/// Test AWS policy variables with multi-value scenarios
#[tokio::test(flavor = "multi_thread")]
#[serial]
#[ignore = "Starts a rustfs server; enable when running full E2E"]
pub async fn test_aws_policy_variables_multi_value() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
test_aws_policy_variables_multi_value_impl().await
}
/// Implementation function for multi-value policy variables test
pub async fn test_aws_policy_variables_multi_value_impl() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
info!("Starting AWS policy variables multi-value test");
let env = PolicyTestEnvironment::with_address("127.0.0.1:9000").await?;
test_aws_policy_variables_multi_value_impl_with_env(&env).await
}
/// Implementation function for multi-value policy variables test with shared environment
pub async fn test_aws_policy_variables_multi_value_impl_with_env(
env: &PolicyTestEnvironment,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
// Create test user
let test_user = "testuser2";
let test_password = "testpassword123";
let policy_name = "test-multi-value-policy";
// Create cleanup function
let cleanup = || async {
cleanup_user_and_policy(env, test_user, policy_name).await;
};
// Create user
create_user(env, test_user, test_password).await?;
// Create policy with multi-value AWS variables
let policy_document = serde_json::json!({
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:ListAllMyBuckets"],
"Resource": ["arn:aws:s3:::*"]
},
{
"Effect": "Allow",
"Action": ["s3:CreateBucket"],
"Resource": [
format!("arn:aws:s3:::{}-bucket1", "${aws:username}"),
format!("arn:aws:s3:::{}-bucket2", "${aws:username}"),
format!("arn:aws:s3:::{}-bucket3", "${aws:username}")
]
},
{
"Effect": "Allow",
"Action": ["s3:ListBucket"],
"Resource": [
format!("arn:aws:s3:::{}-bucket1", "${aws:username}"),
format!("arn:aws:s3:::{}-bucket2", "${aws:username}"),
format!("arn:aws:s3:::{}-bucket3", "${aws:username}")
]
}
]
});
create_and_attach_policy(env, policy_name, test_user, policy_document).await?;
// Create S3 client for test user
let test_client = env.create_s3_client(test_user, test_password);
// Test 1: User should be able to create buckets matching any of the multi-value patterns
info!("Test 1: User creating first bucket matching multi-value pattern");
let bucket1_name = format!("{test_user}-bucket1");
let create_result1 = test_client.create_bucket().bucket(&bucket1_name).send().await;
if let Err(e) = create_result1 {
cleanup().await;
return Err(format!("User should be able to create first bucket matching multi-value pattern: {e}").into());
}
info!("Test 2: User creating second bucket matching multi-value pattern");
let bucket2_name = format!("{test_user}-bucket2");
let create_result2 = test_client.create_bucket().bucket(&bucket2_name).send().await;
if let Err(e) = create_result2 {
cleanup().await;
return Err(format!("User should be able to create second bucket matching multi-value pattern: {e}").into());
}
info!("Test 3: User creating third bucket matching multi-value pattern");
let bucket3_name = format!("{test_user}-bucket3");
let create_result3 = test_client.create_bucket().bucket(&bucket3_name).send().await;
if let Err(e) = create_result3 {
cleanup().await;
return Err(format!("User should be able to create third bucket matching multi-value pattern: {e}").into());
}
// Test 4: User should NOT be able to create bucket NOT matching any multi-value pattern
info!("Test 4: User attempting to create bucket NOT matching any pattern");
let other_bucket_name = format!("{test_user}-other-bucket");
let create_other_result = test_client.create_bucket().bucket(&other_bucket_name).send().await;
if create_other_result.is_ok() {
cleanup().await;
return Err("User should NOT be able to create bucket NOT matching any multi-value pattern".into());
}
// Test 5: User should be able to list objects in their allowed buckets
info!("Test 5: User listing objects in allowed buckets");
let list_objects_result1 = test_client.list_objects_v2().bucket(&bucket1_name).send().await;
if let Err(e) = list_objects_result1 {
cleanup().await;
return Err(format!("User should be able to list objects in first allowed bucket: {e}").into());
}
let list_objects_result2 = test_client.list_objects_v2().bucket(&bucket2_name).send().await;
if let Err(e) = list_objects_result2 {
cleanup().await;
return Err(format!("User should be able to list objects in second allowed bucket: {e}").into());
}
// Cleanup
info!("Cleaning up test resources");
cleanup().await;
info!("AWS policy variables multi-value test completed successfully");
Ok(())
}
/// Test AWS policy variables with variable concatenation
#[tokio::test(flavor = "multi_thread")]
#[serial]
#[ignore = "Starts a rustfs server; enable when running full E2E"]
pub async fn test_aws_policy_variables_concatenation() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
test_aws_policy_variables_concatenation_impl().await
}
/// Implementation function for concatenation policy variables test
pub async fn test_aws_policy_variables_concatenation_impl() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
info!("Starting AWS policy variables concatenation test");
let env = PolicyTestEnvironment::with_address("127.0.0.1:9000").await?;
test_aws_policy_variables_concatenation_impl_with_env(&env).await
}
/// Implementation function for concatenation policy variables test with shared environment
pub async fn test_aws_policy_variables_concatenation_impl_with_env(
env: &PolicyTestEnvironment,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
// Create test user
let test_user = "testuser3";
let test_password = "testpassword123";
let policy_name = "test-concatenation-policy";
// Create cleanup function
let cleanup = || async {
cleanup_user_and_policy(env, test_user, policy_name).await;
};
// Create user
create_user(env, test_user, test_password).await?;
// Create policy with variable concatenation
let policy_document = serde_json::json!({
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:ListAllMyBuckets"],
"Resource": ["arn:aws:s3:::*"]
},
{
"Effect": "Allow",
"Action": ["s3:CreateBucket"],
"Resource": [format!("arn:aws:s3:::prefix-{}-suffix", "${aws:username}")]
},
{
"Effect": "Allow",
"Action": ["s3:ListBucket"],
"Resource": [format!("arn:aws:s3:::prefix-{}-suffix", "${aws:username}")]
}
]
});
create_and_attach_policy(env, policy_name, test_user, policy_document).await?;
// Create S3 client for test user
let test_client = env.create_s3_client(test_user, test_password);
// Add a small delay to allow policy to propagate
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
// Test: User should be able to create bucket matching concatenated pattern
info!("Test: User creating bucket matching concatenated pattern");
let bucket_name = format!("prefix-{test_user}-suffix");
let create_result = test_client.create_bucket().bucket(&bucket_name).send().await;
if let Err(e) = create_result {
cleanup().await;
return Err(format!("User should be able to create bucket matching concatenated pattern: {e}").into());
}
// Test: User should be able to list objects in the concatenated pattern bucket
info!("Test: User listing objects in concatenated pattern bucket");
let list_objects_result = test_client.list_objects_v2().bucket(&bucket_name).send().await;
if let Err(e) = list_objects_result {
cleanup().await;
return Err(format!("User should be able to list objects in concatenated pattern bucket: {e}").into());
}
// Cleanup
info!("Cleaning up test resources");
cleanup().await;
info!("AWS policy variables concatenation test completed successfully");
Ok(())
}
/// Test AWS policy variables with nested scenarios
#[tokio::test(flavor = "multi_thread")]
#[serial]
#[ignore = "Starts a rustfs server; enable when running full E2E"]
pub async fn test_aws_policy_variables_nested() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
test_aws_policy_variables_nested_impl().await
}
/// Implementation function for nested policy variables test
pub async fn test_aws_policy_variables_nested_impl() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
info!("Starting AWS policy variables nested test");
let env = PolicyTestEnvironment::with_address("127.0.0.1:9000").await?;
test_aws_policy_variables_nested_impl_with_env(&env).await
}
/// Test AWS policy variables with STS temporary credentials
#[tokio::test(flavor = "multi_thread")]
#[serial]
#[ignore = "Starts a rustfs server; enable when running full E2E"]
pub async fn test_aws_policy_variables_sts() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
test_aws_policy_variables_sts_impl().await
}
/// Implementation function for STS policy variables test
pub async fn test_aws_policy_variables_sts_impl() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
info!("Starting AWS policy variables STS test");
let env = PolicyTestEnvironment::with_address("127.0.0.1:9000").await?;
test_aws_policy_variables_sts_impl_with_env(&env).await
}
/// Implementation function for nested policy variables test with shared environment
pub async fn test_aws_policy_variables_nested_impl_with_env(
env: &PolicyTestEnvironment,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
// Create test user
let test_user = "testuser4";
let test_password = "testpassword123";
let policy_name = "test-nested-policy";
// Create cleanup function
let cleanup = || async {
cleanup_user_and_policy(env, test_user, policy_name).await;
};
// Create user
create_user(env, test_user, test_password).await?;
// Create policy with nested variables - this tests complex variable resolution
let policy_document = serde_json::json!({
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:ListAllMyBuckets"],
"Resource": ["arn:aws:s3:::*"]
},
{
"Effect": "Allow",
"Action": ["s3:CreateBucket"],
"Resource": ["arn:aws:s3:::${${aws:username}-test}"]
},
{
"Effect": "Allow",
"Action": ["s3:ListBucket"],
"Resource": ["arn:aws:s3:::${${aws:username}-test}"]
}
]
});
create_and_attach_policy(env, policy_name, test_user, policy_document).await?;
// Create S3 client for test user
let test_client = env.create_s3_client(test_user, test_password);
// Add a small delay to allow policy to propagate
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
// Test nested variable resolution
info!("Test: Nested variable resolution");
// Create bucket with expected resolved name
let expected_bucket = format!("{test_user}-test");
// Attempt to create bucket with resolved name
let create_result = test_client.create_bucket().bucket(&expected_bucket).send().await;
// Verify bucket creation succeeds (nested variable resolved correctly)
if let Err(e) = create_result {
cleanup().await;
return Err(format!("User should be able to create bucket with nested variable: {e}").into());
}
// Verify bucket creation fails with unresolved variable
let unresolved_bucket = format!("${{}}-test {test_user}");
let create_unresolved = test_client.create_bucket().bucket(&unresolved_bucket).send().await;
if create_unresolved.is_ok() {
cleanup().await;
return Err("User should NOT be able to create bucket with unresolved variable".into());
}
// Cleanup
info!("Cleaning up test resources");
cleanup().await;
info!("AWS policy variables nested test completed successfully");
Ok(())
}
/// Implementation function for STS policy variables test with shared environment
pub async fn test_aws_policy_variables_sts_impl_with_env(
env: &PolicyTestEnvironment,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
// Create test user for STS
let test_user = "testuser-sts";
let test_password = "testpassword123";
let policy_name = "test-sts-policy";
// Create cleanup function
let cleanup = || async {
cleanup_user_and_policy(env, test_user, policy_name).await;
};
// Create STS user
create_sts_user(env, test_user, test_password).await?;
// Create policy with STS-compatible variables
let policy_document = serde_json::json!({
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:ListAllMyBuckets"],
"Resource": ["arn:aws:s3:::*"]
},
{
"Effect": "Allow",
"Action": ["s3:CreateBucket"],
"Resource": [format!("arn:aws:s3:::{}-sts-bucket", "${aws:username}")]
},
{
"Effect": "Allow",
"Action": ["s3:ListBucket", "s3:PutObject", "s3:GetObject"],
"Resource": [format!("arn:aws:s3:::{}-sts-bucket/*", "${aws:username}")]
}
]
});
create_and_attach_policy(env, policy_name, test_user, policy_document).await?;
// Create S3 client for test user
let test_client = env.create_s3_client(test_user, test_password);
// Add a small delay to allow policy to propagate
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
// Test: User should be able to create bucket matching STS pattern
info!("Test: User creating bucket matching STS pattern");
let bucket_name = format!("{test_user}-sts-bucket");
let create_result = test_client.create_bucket().bucket(&bucket_name).send().await;
if let Err(e) = create_result {
cleanup().await;
return Err(format!("User should be able to create STS bucket: {e}").into());
}
// Test: User should be able to put object in STS bucket
info!("Test: User putting object in STS bucket");
let put_result = test_client
.put_object()
.bucket(&bucket_name)
.key("test-sts-object.txt")
.body(ByteStream::from_static(b"STS Test Object"))
.send()
.await;
if let Err(e) = put_result {
cleanup().await;
return Err(format!("User should be able to put object in STS bucket: {e}").into());
}
// Test: User should be able to get object from STS bucket
info!("Test: User getting object from STS bucket");
let get_result = test_client
.get_object()
.bucket(&bucket_name)
.key("test-sts-object.txt")
.send()
.await;
if let Err(e) = get_result {
cleanup().await;
return Err(format!("User should be able to get object from STS bucket: {e}").into());
}
// Test: User should be able to list objects in STS bucket
info!("Test: User listing objects in STS bucket");
let list_result = test_client.list_objects_v2().bucket(&bucket_name).send().await;
if let Err(e) = list_result {
cleanup().await;
return Err(format!("User should be able to list objects in STS bucket: {e}").into());
}
// Cleanup
info!("Cleaning up test resources");
cleanup().await;
info!("AWS policy variables STS test completed successfully");
Ok(())
}
/// Test AWS policy variables with deny scenarios
#[tokio::test(flavor = "multi_thread")]
#[serial]
#[ignore = "Starts a rustfs server; enable when running full E2E"]
pub async fn test_aws_policy_variables_deny() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
test_aws_policy_variables_deny_impl().await
}
/// Implementation function for deny policy variables test
pub async fn test_aws_policy_variables_deny_impl() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
info!("Starting AWS policy variables deny test");
let env = PolicyTestEnvironment::with_address("127.0.0.1:9000").await?;
test_aws_policy_variables_deny_impl_with_env(&env).await
}
/// Implementation function for deny policy variables test with shared environment
pub async fn test_aws_policy_variables_deny_impl_with_env(
env: &PolicyTestEnvironment,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
// Create test user
let test_user = "testuser5";
let test_password = "testpassword123";
let policy_name = "test-deny-policy";
// Create cleanup function
let cleanup = || async {
cleanup_user_and_policy(env, test_user, policy_name).await;
};
// Create user
create_user(env, test_user, test_password).await?;
// Create policy with both allow and deny statements
let policy_document = serde_json::json!({
"Version": "2012-10-17",
"Statement": [
// Allow general access
{
"Effect": "Allow",
"Action": ["s3:ListAllMyBuckets"],
"Resource": ["arn:aws:s3:::*"]
},
// Allow creating buckets matching username pattern
{
"Effect": "Allow",
"Action": ["s3:CreateBucket"],
"Resource": [format!("arn:aws:s3:::{}-*", "${aws:username}")]
},
// Deny creating buckets with "private" in the name
{
"Effect": "Deny",
"Action": ["s3:CreateBucket"],
"Resource": ["arn:aws:s3:::*private*"]
}
]
});
create_and_attach_policy(env, policy_name, test_user, policy_document).await?;
// Create S3 client for test user
let test_client = env.create_s3_client(test_user, test_password);
// Add a small delay to allow policy to propagate
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
// Test 1: User should be able to create bucket matching username pattern
info!("Test 1: User creating bucket matching username pattern");
let bucket_name = format!("{test_user}-test-bucket");
let create_result = test_client.create_bucket().bucket(&bucket_name).send().await;
if let Err(e) = create_result {
cleanup().await;
return Err(format!("User should be able to create bucket matching username pattern: {e}").into());
}
// Test 2: User should NOT be able to create bucket with "private" in the name (deny rule)
info!("Test 2: User attempting to create bucket with 'private' in name (should be denied)");
let private_bucket_name = "private-test-bucket";
let create_private_result = test_client.create_bucket().bucket(private_bucket_name).send().await;
if create_private_result.is_ok() {
cleanup().await;
return Err("User should NOT be able to create bucket with 'private' in name due to deny rule".into());
}
// Cleanup
info!("Cleaning up test resources");
cleanup().await;
info!("AWS policy variables deny test completed successfully");
Ok(())
}

View File

@@ -0,0 +1,100 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Custom test environment for policy variables tests
//!
//! This module provides a custom test environment that doesn't automatically
//! stop servers when destroyed, addressing the server stopping issue.
use aws_sdk_s3::Client;
use aws_sdk_s3::config::{Config, Credentials, Region};
use std::net::TcpStream;
use std::time::Duration;
use tokio::time::sleep;
use tracing::{info, warn};
// Default credentials
const DEFAULT_ACCESS_KEY: &str = "rustfsadmin";
const DEFAULT_SECRET_KEY: &str = "rustfsadmin";
/// Custom test environment that doesn't automatically stop servers
pub struct PolicyTestEnvironment {
pub temp_dir: String,
pub address: String,
pub url: String,
pub access_key: String,
pub secret_key: String,
}
impl PolicyTestEnvironment {
/// Create a new test environment with specific address
/// This environment won't stop any server when dropped
pub async fn with_address(address: &str) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
let temp_dir = format!("/tmp/rustfs_policy_test_{}", uuid::Uuid::new_v4());
tokio::fs::create_dir_all(&temp_dir).await?;
let url = format!("http://{address}");
Ok(Self {
temp_dir,
address: address.to_string(),
url,
access_key: DEFAULT_ACCESS_KEY.to_string(),
secret_key: DEFAULT_SECRET_KEY.to_string(),
})
}
/// Create an AWS S3 client configured for this RustFS instance
pub fn create_s3_client(&self, access_key: &str, secret_key: &str) -> Client {
let credentials = Credentials::new(access_key, secret_key, None, None, "policy-test");
let config = Config::builder()
.credentials_provider(credentials)
.region(Region::new("us-east-1"))
.endpoint_url(&self.url)
.force_path_style(true)
.behavior_version_latest()
.build();
Client::from_conf(config)
}
/// Wait for RustFS server to be ready by checking TCP connectivity
pub async fn wait_for_server_ready(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
info!("Waiting for RustFS server to be ready on {}", self.address);
for i in 0..30 {
if TcpStream::connect(&self.address).is_ok() {
info!("✅ RustFS server is ready after {} attempts", i + 1);
return Ok(());
}
if i == 29 {
return Err("RustFS server failed to become ready within 30 seconds".into());
}
sleep(Duration::from_secs(1)).await;
}
Ok(())
}
}
// Implement Drop trait that doesn't stop servers
impl Drop for PolicyTestEnvironment {
fn drop(&mut self) {
// Clean up temp directory only, don't stop any server
if let Err(e) = std::fs::remove_dir_all(&self.temp_dir) {
warn!("Failed to clean up temp directory {}: {}", self.temp_dir, e);
}
}
}

View File

@@ -0,0 +1,247 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use crate::common::init_logging;
use crate::policy::test_env::PolicyTestEnvironment;
use serial_test::serial;
use std::time::Instant;
use tokio::time::{Duration, sleep};
use tracing::{error, info};
/// Core test categories
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TestCategory {
SingleValue,
MultiValue,
Concatenation,
Nested,
DenyScenarios,
}
impl TestCategory {}
/// Test case definition
#[derive(Debug, Clone)]
pub struct TestDefinition {
pub name: String,
#[allow(dead_code)]
pub category: TestCategory,
pub is_critical: bool,
}
impl TestDefinition {
pub fn new(name: impl Into<String>, category: TestCategory, is_critical: bool) -> Self {
Self {
name: name.into(),
category,
is_critical,
}
}
}
/// Test result
#[derive(Debug, Clone)]
pub struct TestResult {
pub test_name: String,
pub success: bool,
pub error_message: Option<String>,
}
impl TestResult {
pub fn success(test_name: String) -> Self {
Self {
test_name,
success: true,
error_message: None,
}
}
pub fn failure(test_name: String, error: String) -> Self {
Self {
test_name,
success: false,
error_message: Some(error),
}
}
}
/// Test suite configuration
#[derive(Debug, Clone, Default)]
pub struct TestSuiteConfig {
pub include_critical_only: bool,
}
/// Policy test suite
pub struct PolicyTestSuite {
tests: Vec<TestDefinition>,
config: TestSuiteConfig,
}
impl PolicyTestSuite {
/// Create default test suite
pub fn new() -> Self {
let tests = vec![
TestDefinition::new("test_aws_policy_variables_single_value", TestCategory::SingleValue, true),
TestDefinition::new("test_aws_policy_variables_multi_value", TestCategory::MultiValue, true),
TestDefinition::new("test_aws_policy_variables_concatenation", TestCategory::Concatenation, true),
TestDefinition::new("test_aws_policy_variables_nested", TestCategory::Nested, true),
TestDefinition::new("test_aws_policy_variables_deny", TestCategory::DenyScenarios, true),
TestDefinition::new("test_aws_policy_variables_sts", TestCategory::SingleValue, true),
];
Self {
tests,
config: TestSuiteConfig::default(),
}
}
/// Configure test suite
pub fn with_config(mut self, config: TestSuiteConfig) -> Self {
self.config = config;
self
}
/// Run test suite
pub async fn run_test_suite(&self) -> Vec<TestResult> {
init_logging();
info!("Starting Policy Variables test suite");
let start_time = Instant::now();
let mut results = Vec::new();
// Create test environment
let env = match PolicyTestEnvironment::with_address("127.0.0.1:9000").await {
Ok(env) => env,
Err(e) => {
error!("Failed to create test environment: {}", e);
return vec![TestResult::failure("env_creation".into(), e.to_string())];
}
};
// Wait for server to be ready
if env.wait_for_server_ready().await.is_err() {
error!("Server is not ready");
return vec![TestResult::failure("server_check".into(), "Server not ready".into())];
}
// Filter tests
let tests_to_run: Vec<&TestDefinition> = self
.tests
.iter()
.filter(|test| !self.config.include_critical_only || test.is_critical)
.collect();
info!("Scheduled {} tests", tests_to_run.len());
// Run tests
for (i, test_def) in tests_to_run.iter().enumerate() {
info!("Running test {}/{}: {}", i + 1, tests_to_run.len(), test_def.name);
let test_start = Instant::now();
let result = self.run_single_test(test_def, &env).await;
let test_duration = test_start.elapsed();
match result {
Ok(_) => {
info!("Test passed: {} ({:.2}s)", test_def.name, test_duration.as_secs_f64());
results.push(TestResult::success(test_def.name.clone()));
}
Err(e) => {
error!("Test failed: {} ({:.2}s): {}", test_def.name, test_duration.as_secs_f64(), e);
results.push(TestResult::failure(test_def.name.clone(), e.to_string()));
}
}
// Delay between tests to avoid resource conflicts
if i < tests_to_run.len() - 1 {
sleep(Duration::from_secs(2)).await;
}
}
// Print summary
self.print_summary(&results, start_time.elapsed());
results
}
/// Run a single test
async fn run_single_test(
&self,
test_def: &TestDefinition,
env: &PolicyTestEnvironment,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
match test_def.name.as_str() {
"test_aws_policy_variables_single_value" => {
super::policy_variables_test::test_aws_policy_variables_single_value_impl_with_env(env).await
}
"test_aws_policy_variables_multi_value" => {
super::policy_variables_test::test_aws_policy_variables_multi_value_impl_with_env(env).await
}
"test_aws_policy_variables_concatenation" => {
super::policy_variables_test::test_aws_policy_variables_concatenation_impl_with_env(env).await
}
"test_aws_policy_variables_nested" => {
super::policy_variables_test::test_aws_policy_variables_nested_impl_with_env(env).await
}
"test_aws_policy_variables_deny" => {
super::policy_variables_test::test_aws_policy_variables_deny_impl_with_env(env).await
}
"test_aws_policy_variables_sts" => {
super::policy_variables_test::test_aws_policy_variables_sts_impl_with_env(env).await
}
_ => Err(format!("Test {} not implemented", test_def.name).into()),
}
}
/// Print test summary
fn print_summary(&self, results: &[TestResult], total_duration: Duration) {
info!("=== Test Suite Summary ===");
info!("Total duration: {:.2}s", total_duration.as_secs_f64());
info!("Total tests: {}", results.len());
let passed = results.iter().filter(|r| r.success).count();
let failed = results.len() - passed;
let success_rate = (passed as f64 / results.len() as f64) * 100.0;
info!("Passed: {} | Failed: {}", passed, failed);
info!("Success rate: {:.1}%", success_rate);
if failed > 0 {
error!("Failed tests:");
for result in results.iter().filter(|r| !r.success) {
error!(" - {}: {}", result.test_name, result.error_message.as_ref().unwrap());
}
}
}
}
/// Test suite
#[tokio::test]
#[serial]
#[ignore = "Connects to existing rustfs server"]
async fn test_policy_critical_suite() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let config = TestSuiteConfig {
include_critical_only: true,
};
let suite = PolicyTestSuite::new().with_config(config);
let results = suite.run_test_suite().await;
let failed = results.iter().filter(|r| !r.success).count();
if failed > 0 {
return Err(format!("Critical tests failed: {failed} failures").into());
}
info!("All critical tests passed");
Ok(())
}

View File

@@ -45,6 +45,7 @@ regex = { workspace = true }
reqwest.workspace = true
chrono.workspace = true
tracing.workspace = true
lru.workspace = true
[dev-dependencies]
test-case.workspace = true

View File

@@ -24,6 +24,7 @@ mod principal;
pub mod resource;
pub mod statement;
pub(crate) mod utils;
pub mod variables;
pub use action::ActionSet;
pub use doc::PolicyDoc;

View File

@@ -13,6 +13,7 @@
// limitations under the License.
use crate::policy::function::condition::Condition;
use crate::policy::variables::PolicyVariableResolver;
use serde::ser::SerializeMap;
use serde::{Deserialize, Serialize, Serializer, de};
use std::collections::HashMap;
@@ -38,20 +39,28 @@ pub struct Functions {
impl Functions {
pub fn evaluate(&self, values: &HashMap<String, Vec<String>>) -> bool {
self.evaluate_with_resolver(values, None)
}
pub fn evaluate_with_resolver(
&self,
values: &HashMap<String, Vec<String>>,
resolver: Option<&dyn PolicyVariableResolver>,
) -> bool {
for c in self.for_any_value.iter() {
if !c.evaluate(false, values) {
if !c.evaluate_with_resolver(false, values, resolver) {
return false;
}
}
for c in self.for_all_values.iter() {
if !c.evaluate(true, values) {
if !c.evaluate_with_resolver(true, values, resolver) {
return false;
}
}
for c in self.for_normal.iter() {
if !c.evaluate(false, values) {
if !c.evaluate_with_resolver(false, values, resolver) {
return false;
}
}

View File

@@ -12,6 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use crate::policy::variables::PolicyVariableResolver;
use serde::Deserialize;
use serde::de::{Error, MapAccess};
use serde::ser::SerializeMap;
@@ -106,16 +107,21 @@ impl Condition {
}
}
pub fn evaluate(&self, for_all: bool, values: &HashMap<String, Vec<String>>) -> bool {
pub fn evaluate_with_resolver(
&self,
for_all: bool,
values: &HashMap<String, Vec<String>>,
resolver: Option<&dyn PolicyVariableResolver>,
) -> bool {
use Condition::*;
let r = match self {
StringEquals(s) => s.evaluate(for_all, false, false, false, values),
StringNotEquals(s) => s.evaluate(for_all, false, false, true, values),
StringEqualsIgnoreCase(s) => s.evaluate(for_all, true, false, false, values),
StringNotEqualsIgnoreCase(s) => s.evaluate(for_all, true, false, true, values),
StringLike(s) => s.evaluate(for_all, false, true, false, values),
StringNotLike(s) => s.evaluate(for_all, false, true, true, values),
StringEquals(s) => s.evaluate_with_resolver(for_all, false, false, false, values, resolver),
StringNotEquals(s) => s.evaluate_with_resolver(for_all, false, false, true, values, resolver),
StringEqualsIgnoreCase(s) => s.evaluate_with_resolver(for_all, true, false, false, values, resolver),
StringNotEqualsIgnoreCase(s) => s.evaluate_with_resolver(for_all, true, false, true, values, resolver),
StringLike(s) => s.evaluate_with_resolver(for_all, false, true, false, values, resolver),
StringNotLike(s) => s.evaluate_with_resolver(for_all, false, true, true, values, resolver),
BinaryEquals(s) => s.evaluate(values),
IpAddress(s) => s.evaluate(values),
NotIpAddress(s) => s.evaluate(values),

View File

@@ -24,23 +24,26 @@ use crate::policy::utils::wildcard;
use serde::{Deserialize, Deserializer, Serialize, de, ser::SerializeSeq};
use super::{func::InnerFunc, key_name::KeyName};
use crate::policy::variables::{PolicyVariableResolver, resolve_aws_variables};
pub type StringFunc = InnerFunc<StringFuncValue>;
impl StringFunc {
pub(crate) fn evaluate(
#[allow(clippy::too_many_arguments)]
pub(crate) fn evaluate_with_resolver(
&self,
for_all: bool,
ignore_case: bool,
like: bool,
negate: bool,
values: &HashMap<String, Vec<String>>,
resolver: Option<&dyn PolicyVariableResolver>,
) -> bool {
for inner in self.0.iter() {
let result = if like {
inner.eval_like(for_all, values) ^ negate
inner.eval_like(for_all, values, resolver) ^ negate
} else {
inner.eval(for_all, ignore_case, values) ^ negate
inner.eval(for_all, ignore_case, values, resolver) ^ negate
};
if !result {
@@ -53,7 +56,13 @@ impl StringFunc {
}
impl FuncKeyValue<StringFuncValue> {
fn eval(&self, for_all: bool, ignore_case: bool, values: &HashMap<String, Vec<String>>) -> bool {
fn eval(
&self,
for_all: bool,
ignore_case: bool,
values: &HashMap<String, Vec<String>>,
resolver: Option<&dyn PolicyVariableResolver>,
) -> bool {
let rvalues = values
// http.CanonicalHeaderKey ?
.get(self.key.name().as_str())
@@ -74,8 +83,15 @@ impl FuncKeyValue<StringFuncValue> {
.values
.0
.iter()
.map(|c| {
let mut c = Cow::from(c);
.flat_map(|c| {
if let Some(res) = resolver {
resolve_aws_variables(c, res)
} else {
vec![c.to_string()]
}
})
.map(|resolved_c| {
let mut c = Cow::from(resolved_c);
for key in KeyName::COMMON_KEYS {
match values.get(key.name()).and_then(|x| x.first()) {
Some(v) if !v.is_empty() => return Cow::Owned(c.to_mut().replace(&key.var_name(), v)),
@@ -97,15 +113,27 @@ impl FuncKeyValue<StringFuncValue> {
}
}
fn eval_like(&self, for_all: bool, values: &HashMap<String, Vec<String>>) -> bool {
fn eval_like(
&self,
for_all: bool,
values: &HashMap<String, Vec<String>>,
resolver: Option<&dyn PolicyVariableResolver>,
) -> bool {
if let Some(rvalues) = values.get(self.key.name().as_str()) {
for v in rvalues.iter() {
let matched = self
.values
.0
.iter()
.map(|c| {
let mut c = Cow::from(c);
.flat_map(|c| {
if let Some(res) = resolver {
resolve_aws_variables(c, res)
} else {
vec![c.to_string()]
}
})
.map(|resolved_c| {
let mut c = Cow::from(resolved_c);
for key in KeyName::COMMON_KEYS {
match values.get(key.name()).and_then(|x| x.first()) {
Some(v) if !v.is_empty() => return Cow::Owned(c.to_mut().replace(&key.var_name(), v)),
@@ -282,6 +310,7 @@ mod tests {
.into_iter()
.map(|(k, v)| (k.to_owned(), v.into_iter().map(ToOwned::to_owned).collect::<Vec<String>>()))
.collect(),
None,
);
result ^ negate
@@ -386,6 +415,7 @@ mod tests {
.into_iter()
.map(|(k, v)| (k.to_owned(), v.into_iter().map(ToOwned::to_owned).collect::<Vec<String>>()))
.collect(),
None,
);
result ^ negate

View File

@@ -525,4 +525,281 @@ mod test {
// assert_eq!(p, p2);
Ok(())
}
#[tokio::test]
async fn test_aws_username_policy_variable() -> Result<()> {
let data = r#"
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:ListBucket"],
"Resource": ["arn:aws:s3:::${aws:username}-*"]
}
]
}
"#;
let policy = Policy::parse_config(data.as_bytes())?;
let conditions = HashMap::new();
// Test allowed case - user testuser accessing testuser-bucket
let mut claims1 = HashMap::new();
claims1.insert("username".to_string(), Value::String("testuser".to_string()));
let args1 = Args {
account: "testuser",
groups: &None,
action: Action::S3Action(crate::policy::action::S3Action::ListBucketAction),
bucket: "testuser-bucket",
conditions: &conditions,
is_owner: false,
object: "",
claims: &claims1,
deny_only: false,
};
// Test denied case - user otheruser accessing testuser-bucket
let mut claims2 = HashMap::new();
claims2.insert("username".to_string(), Value::String("otheruser".to_string()));
let args2 = Args {
account: "otheruser",
groups: &None,
action: Action::S3Action(crate::policy::action::S3Action::ListBucketAction),
bucket: "testuser-bucket",
conditions: &conditions,
is_owner: false,
object: "",
claims: &claims2,
deny_only: false,
};
assert!(policy.is_allowed(&args1));
assert!(!policy.is_allowed(&args2));
Ok(())
}
#[tokio::test]
async fn test_aws_userid_policy_variable() -> Result<()> {
let data = r#"
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:ListBucket"],
"Resource": ["arn:aws:s3:::${aws:userid}-bucket"]
}
]
}
"#;
let policy = Policy::parse_config(data.as_bytes())?;
let mut claims = HashMap::new();
claims.insert("sub".to_string(), Value::String("AIDACKCEVSQ6C2EXAMPLE".to_string()));
let conditions = HashMap::new();
// Test allowed case
let args1 = Args {
account: "testuser",
groups: &None,
action: Action::S3Action(crate::policy::action::S3Action::ListBucketAction),
bucket: "AIDACKCEVSQ6C2EXAMPLE-bucket",
conditions: &conditions,
is_owner: false,
object: "",
claims: &claims,
deny_only: false,
};
// Test denied case
let args2 = Args {
account: "testuser",
groups: &None,
action: Action::S3Action(crate::policy::action::S3Action::ListBucketAction),
bucket: "OTHERUSER-bucket",
conditions: &conditions,
is_owner: false,
object: "",
claims: &claims,
deny_only: false,
};
assert!(policy.is_allowed(&args1));
assert!(!policy.is_allowed(&args2));
Ok(())
}
#[tokio::test]
async fn test_aws_policy_variables_concatenation() -> Result<()> {
let data = r#"
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:ListBucket"],
"Resource": ["arn:aws:s3:::${aws:username}-${aws:userid}-bucket"]
}
]
}
"#;
let policy = Policy::parse_config(data.as_bytes())?;
let mut claims = HashMap::new();
claims.insert("username".to_string(), Value::String("testuser".to_string()));
claims.insert("sub".to_string(), Value::String("AIDACKCEVSQ6C2EXAMPLE".to_string()));
let conditions = HashMap::new();
// Test allowed case
let args1 = Args {
account: "testuser",
groups: &None,
action: Action::S3Action(crate::policy::action::S3Action::ListBucketAction),
bucket: "testuser-AIDACKCEVSQ6C2EXAMPLE-bucket",
conditions: &conditions,
is_owner: false,
object: "",
claims: &claims,
deny_only: false,
};
// Test denied case
let args2 = Args {
account: "testuser",
groups: &None,
action: Action::S3Action(crate::policy::action::S3Action::ListBucketAction),
bucket: "otheruser-AIDACKCEVSQ6C2EXAMPLE-bucket",
conditions: &conditions,
is_owner: false,
object: "",
claims: &claims,
deny_only: false,
};
assert!(policy.is_allowed(&args1));
assert!(!policy.is_allowed(&args2));
Ok(())
}
#[tokio::test]
async fn test_aws_policy_variables_nested() -> Result<()> {
let data = r#"
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:ListBucket"],
"Resource": ["arn:aws:s3:::${${aws:PrincipalType}-${aws:userid}}"]
}
]
}
"#;
let policy = Policy::parse_config(data.as_bytes())?;
let mut claims = HashMap::new();
claims.insert("sub".to_string(), Value::String("AIDACKCEVSQ6C2EXAMPLE".to_string()));
// For PrincipalType, it will default to "User" when not explicitly set
let conditions = HashMap::new();
// Test allowed case
let args1 = Args {
account: "testuser",
groups: &None,
action: Action::S3Action(crate::policy::action::S3Action::ListBucketAction),
bucket: "User-AIDACKCEVSQ6C2EXAMPLE",
conditions: &conditions,
is_owner: false,
object: "",
claims: &claims,
deny_only: false,
};
// Test denied case
let args2 = Args {
account: "testuser",
groups: &None,
action: Action::S3Action(crate::policy::action::S3Action::ListBucketAction),
bucket: "User-OTHERUSER",
conditions: &conditions,
is_owner: false,
object: "",
claims: &claims,
deny_only: false,
};
assert!(policy.is_allowed(&args1));
assert!(!policy.is_allowed(&args2));
Ok(())
}
#[tokio::test]
async fn test_aws_policy_variables_multi_value() -> Result<()> {
let data = r#"
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:ListBucket"],
"Resource": ["arn:aws:s3:::${aws:username}-bucket"]
}
]
}
"#;
let policy = Policy::parse_config(data.as_bytes())?;
let mut claims = HashMap::new();
// Test with array value for username
claims.insert(
"username".to_string(),
Value::Array(vec![Value::String("user1".to_string()), Value::String("user2".to_string())]),
);
let conditions = HashMap::new();
let args1 = Args {
account: "user1",
groups: &None,
action: Action::S3Action(crate::policy::action::S3Action::ListBucketAction),
bucket: "user1-bucket",
conditions: &conditions,
is_owner: false,
object: "",
claims: &claims,
deny_only: false,
};
let args2 = Args {
account: "user2",
groups: &None,
action: Action::S3Action(crate::policy::action::S3Action::ListBucketAction),
bucket: "user2-bucket",
conditions: &conditions,
is_owner: false,
object: "",
claims: &claims,
deny_only: false,
};
// Either user1 or user2 should be allowed
assert!(policy.is_allowed(&args1) || policy.is_allowed(&args2));
Ok(())
}
}

View File

@@ -24,6 +24,7 @@ use super::{
Error as IamError, Validator,
function::key_name::KeyName,
utils::{path, wildcard},
variables::{PolicyVariableResolver, resolve_aws_variables},
};
#[derive(Serialize, Deserialize, Clone, Default, Debug)]
@@ -31,8 +32,17 @@ pub struct ResourceSet(pub HashSet<Resource>);
impl ResourceSet {
pub fn is_match(&self, resource: &str, conditions: &HashMap<String, Vec<String>>) -> bool {
self.is_match_with_resolver(resource, conditions, None)
}
pub fn is_match_with_resolver(
&self,
resource: &str,
conditions: &HashMap<String, Vec<String>>,
resolver: Option<&dyn PolicyVariableResolver>,
) -> bool {
for re in self.0.iter() {
if re.is_match(resource, conditions) {
if re.is_match_with_resolver(resource, conditions, resolver) {
return true;
}
}
@@ -86,26 +96,51 @@ impl Resource {
pub const S3_PREFIX: &'static str = "arn:aws:s3:::";
pub fn is_match(&self, resource: &str, conditions: &HashMap<String, Vec<String>>) -> bool {
let mut pattern = match self {
self.is_match_with_resolver(resource, conditions, None)
}
pub fn is_match_with_resolver(
&self,
resource: &str,
conditions: &HashMap<String, Vec<String>>,
resolver: Option<&dyn PolicyVariableResolver>,
) -> bool {
let pattern = match self {
Resource::S3(s) => s.to_owned(),
Resource::Kms(s) => s.to_owned(),
};
if !conditions.is_empty() {
for key in KeyName::COMMON_KEYS {
if let Some(rvalue) = conditions.get(key.name()) {
if matches!(rvalue.first().map(|c| !c.is_empty()), Some(true)) {
pattern = pattern.replace(&key.var_name(), &rvalue[0]);
let patterns = if let Some(res) = resolver {
resolve_aws_variables(&pattern, res)
} else {
vec![pattern.clone()]
};
for pattern in patterns {
let mut resolved_pattern = pattern;
// Apply condition substitutions
if !conditions.is_empty() {
for key in KeyName::COMMON_KEYS {
if let Some(rvalue) = conditions.get(key.name()) {
if matches!(rvalue.first().map(|c| !c.is_empty()), Some(true)) {
resolved_pattern = resolved_pattern.replace(&key.var_name(), &rvalue[0]);
}
}
}
}
let cp = path::clean(resource);
if cp != "." && cp == resolved_pattern.as_str() {
return true;
}
if wildcard::is_match(resolved_pattern, resource) {
return true;
}
}
let cp = path::clean(resource);
if cp != "." && cp == pattern.as_str() {
return true;
}
wildcard::is_match(pattern, resource)
false
}
pub fn match_resource(&self, resource: &str) -> bool {

View File

@@ -15,6 +15,7 @@
use super::{
ActionSet, Args, BucketPolicyArgs, Effect, Error as IamError, Functions, ID, Principal, ResourceSet, Validator,
action::Action,
variables::{VariableContext, VariableResolver},
};
use crate::error::{Error, Result};
use serde::{Deserialize, Serialize};
@@ -69,6 +70,23 @@ impl Statement {
}
pub fn is_allowed(&self, args: &Args) -> bool {
let mut context = VariableContext::new();
context.claims = Some(args.claims.clone());
context.conditions = args.conditions.clone();
context.account_id = Some(args.account.to_string());
let username = if let Some(parent) = args.claims.get("parent").and_then(|v| v.as_str()) {
// For temp credentials or service account credentials, username is parent_user
parent.to_string()
} else {
// For regular user credentials, username is access_key
args.account.to_string()
};
context.username = Some(username);
let resolver = VariableResolver::new(context);
let check = 'c: {
if (!self.actions.is_match(&args.action) && !self.actions.is_empty()) || self.not_actions.is_match(&args.action) {
break 'c false;
@@ -86,14 +104,19 @@ impl Statement {
}
if self.is_kms() && (resource == "/" || self.resources.is_empty()) {
break 'c self.conditions.evaluate(args.conditions);
break 'c self.conditions.evaluate_with_resolver(args.conditions, Some(&resolver));
}
if !self.resources.is_match(&resource, args.conditions) && !self.is_admin() && !self.is_sts() {
if !self
.resources
.is_match_with_resolver(&resource, args.conditions, Some(&resolver))
&& !self.is_admin()
&& !self.is_sts()
{
break 'c false;
}
self.conditions.evaluate(args.conditions)
self.conditions.evaluate_with_resolver(args.conditions, Some(&resolver))
};
self.effect.is_allowed(check)

View File

@@ -0,0 +1,491 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use lru::LruCache;
use serde_json::Value;
use std::cell::RefCell;
use std::collections::HashMap;
use std::num::NonZeroUsize;
use std::time::{Duration, Instant};
use time::OffsetDateTime;
/// Context information for variable resolution
#[derive(Debug, Clone)]
pub struct VariableContext {
pub is_https: bool,
pub source_ip: Option<String>,
pub account_id: Option<String>,
pub region: Option<String>,
pub username: Option<String>,
pub claims: Option<HashMap<String, Value>>,
pub conditions: HashMap<String, Vec<String>>,
pub custom_variables: HashMap<String, String>,
}
impl VariableContext {
pub fn new() -> Self {
Self {
is_https: false,
source_ip: None,
account_id: None,
region: None,
username: None,
claims: None,
conditions: HashMap::new(),
custom_variables: HashMap::new(),
}
}
}
impl Default for VariableContext {
fn default() -> Self {
Self::new()
}
}
/// Variable resolution cache
struct CachedVariable {
value: String,
timestamp: Instant,
is_dynamic: bool,
}
pub struct VariableResolverCache {
/// LRU cache storing resolved results
cache: LruCache<String, CachedVariable>,
/// Cache expiration time
ttl: Duration,
}
impl VariableResolverCache {
pub fn new(capacity: usize, ttl_seconds: u64) -> Self {
Self {
cache: LruCache::new(usize::from(NonZeroUsize::new(capacity).unwrap_or(NonZeroUsize::new(100).unwrap()))),
ttl: Duration::from_secs(ttl_seconds),
}
}
pub fn get(&mut self, key: &str) -> Option<String> {
if let Some(cached) = self.cache.get(key) {
// Check if expired
if !cached.is_dynamic && cached.timestamp.elapsed() < self.ttl {
return Some(cached.value.clone());
}
}
None
}
pub fn put(&mut self, key: String, value: String, is_dynamic: bool) {
let cached = CachedVariable {
value,
timestamp: Instant::now(),
is_dynamic,
};
self.cache.put(key, cached);
}
pub fn clear(&mut self) {
self.cache.clear();
}
}
/// Cached dynamic AWS variable resolver
pub struct CachedAwsVariableResolver {
inner: VariableResolver,
cache: RefCell<VariableResolverCache>,
}
impl CachedAwsVariableResolver {
pub fn new(context: VariableContext) -> Self {
Self {
inner: VariableResolver::new(context),
cache: RefCell::new(VariableResolverCache::new(100, 300)), // 100 entries, 5 minutes expiration
}
}
}
impl PolicyVariableResolver for CachedAwsVariableResolver {
fn resolve(&self, variable_name: &str) -> Option<String> {
if self.is_dynamic(variable_name) {
return self.inner.resolve(variable_name);
}
if let Some(cached) = self.cache.borrow_mut().get(variable_name) {
return Some(cached);
}
let value = self.inner.resolve(variable_name)?;
self.cache.borrow_mut().put(variable_name.to_string(), value.clone(), false);
Some(value)
}
fn resolve_multiple(&self, variable_name: &str) -> Option<Vec<String>> {
if self.is_dynamic(variable_name) {
return self.inner.resolve_multiple(variable_name);
}
self.inner.resolve_multiple(variable_name)
}
fn is_dynamic(&self, variable_name: &str) -> bool {
self.inner.is_dynamic(variable_name)
}
}
/// Policy variable resolver trait
pub trait PolicyVariableResolver {
fn resolve(&self, variable_name: &str) -> Option<String>;
fn resolve_multiple(&self, variable_name: &str) -> Option<Vec<String>> {
self.resolve(variable_name).map(|s| vec![s])
}
fn is_dynamic(&self, variable_name: &str) -> bool;
}
/// AWS variable resolver
pub struct VariableResolver {
context: VariableContext,
}
impl VariableResolver {
pub fn new(context: VariableContext) -> Self {
Self { context }
}
fn get_claim_as_strings(&self, claim_name: &str) -> Option<Vec<String>> {
self.context
.claims
.as_ref()
.and_then(|claims| claims.get(claim_name))
.and_then(|value| match value {
Value::String(s) => Some(vec![s.clone()]),
Value::Array(arr) => Some(
arr.iter()
.filter_map(|item| match item {
Value::String(s) => Some(s.clone()),
Value::Number(n) => Some(n.to_string()),
Value::Bool(b) => Some(b.to_string()),
_ => None,
})
.collect(),
),
Value::Number(n) => Some(vec![n.to_string()]),
Value::Bool(b) => Some(vec![b.to_string()]),
_ => None,
})
}
fn resolve_username(&self) -> Option<String> {
self.context.username.clone()
}
fn resolve_userid(&self) -> Option<String> {
// Check claims for sub or parent
if let Some(claims) = &self.context.claims {
if let Some(sub) = claims.get("sub").and_then(|v| v.as_str()) {
return Some(sub.to_string());
}
if let Some(parent) = claims.get("parent").and_then(|v| v.as_str()) {
return Some(parent.to_string());
}
}
None
}
fn resolve_principal_type(&self) -> String {
if let Some(claims) = &self.context.claims {
if claims.contains_key("roleArn") {
return "AssumedRole".to_string();
}
if claims.contains_key("parent") && claims.contains_key("sa-policy") {
return "ServiceAccount".to_string();
}
}
"User".to_string()
}
fn resolve_secure_transport(&self) -> String {
if self.context.is_https { "true" } else { "false" }.to_string()
}
fn resolve_current_time(&self) -> String {
let now = OffsetDateTime::now_utc();
now.format(&time::format_description::well_known::Rfc3339)
.unwrap_or_else(|_| now.to_string())
}
fn resolve_epoch_time(&self) -> String {
OffsetDateTime::now_utc().unix_timestamp().to_string()
}
fn resolve_account_id(&self) -> Option<String> {
self.context.account_id.clone()
}
fn resolve_region(&self) -> Option<String> {
self.context.region.clone()
}
fn resolve_source_ip(&self) -> Option<String> {
self.context.source_ip.clone()
}
fn resolve_custom_variable(&self, variable_name: &str) -> Option<String> {
let custom_key = variable_name.strip_prefix("custom:")?;
self.context.custom_variables.get(custom_key).cloned()
}
}
impl PolicyVariableResolver for VariableResolver {
fn resolve(&self, variable_name: &str) -> Option<String> {
match variable_name {
"aws:username" => self.resolve_username(),
"aws:userid" => self.resolve_userid(),
"aws:PrincipalType" => Some(self.resolve_principal_type()),
"aws:SecureTransport" => Some(self.resolve_secure_transport()),
"aws:CurrentTime" => Some(self.resolve_current_time()),
"aws:EpochTime" => Some(self.resolve_epoch_time()),
"aws:AccountId" => self.resolve_account_id(),
"aws:Region" => self.resolve_region(),
"aws:SourceIp" => self.resolve_source_ip(),
_ => {
// Handle custom:* variables
if variable_name.starts_with("custom:") {
self.resolve_custom_variable(variable_name)
} else {
None
}
}
}
}
fn resolve_multiple(&self, variable_name: &str) -> Option<Vec<String>> {
match variable_name {
"aws:username" => {
// Check context.username
if let Some(ref username) = self.context.username {
Some(vec![username.clone()])
} else {
None
}
}
"aws:userid" => {
// Check claims for sub or parent
self.get_claim_as_strings("sub")
.or_else(|| self.get_claim_as_strings("parent"))
}
_ => self.resolve(variable_name).map(|s| vec![s]),
}
}
fn is_dynamic(&self, variable_name: &str) -> bool {
matches!(variable_name, "aws:CurrentTime" | "aws:EpochTime")
}
}
/// Dynamically resolve AWS variables
pub fn resolve_aws_variables(pattern: &str, resolver: &dyn PolicyVariableResolver) -> Vec<String> {
let mut results = vec![pattern.to_string()];
let mut changed = true;
let max_iterations = 10; // Prevent infinite loops
let mut iteration = 0;
while changed && iteration < max_iterations {
changed = false;
iteration += 1;
let mut new_results = Vec::new();
for result in &results {
let resolved = resolve_single_pass(result, resolver);
if resolved.len() > 1 || (resolved.len() == 1 && &resolved[0] != result) {
changed = true;
}
new_results.extend(resolved);
}
// Remove duplicates while preserving order
results.clear();
let mut seen = std::collections::HashSet::new();
for result in new_results {
if seen.insert(result.clone()) {
results.push(result);
}
}
}
results
}
/// Single pass resolution of variables in a string
fn resolve_single_pass(pattern: &str, resolver: &dyn PolicyVariableResolver) -> Vec<String> {
// Find all ${...} format variables
let mut results = vec![pattern.to_string()];
// Process each result string
let mut i = 0;
while i < results.len() {
let mut start = 0;
let mut modified = false;
// Find variables in current string
while let Some(pos) = results[i][start..].find("${") {
let actual_pos = start + pos;
// Find the matching closing brace, taking into account nested braces
let mut brace_count = 1;
let mut end_pos = actual_pos + 2; // Start after "${"
while end_pos < results[i].len() && brace_count > 0 {
match results[i].chars().nth(end_pos).unwrap() {
'{' => brace_count += 1,
'}' => brace_count -= 1,
_ => {}
}
if brace_count > 0 {
end_pos += 1;
}
}
if brace_count == 0 {
let var_name = &results[i][actual_pos + 2..end_pos];
// Check if this is a nested variable (contains ${...} inside)
if var_name.contains("${") {
// For nested variables like ${${a}-${b}}, we need to resolve the inner variables first
// Then use the resolved result as a new variable to resolve
let resolved_inner = resolve_aws_variables(var_name, resolver);
let mut new_results = Vec::new();
for resolved_var_name in resolved_inner {
let prefix = &results[i][..actual_pos];
let suffix = &results[i][end_pos + 1..];
new_results.push(format!("{prefix}{resolved_var_name}{suffix}"));
}
if !new_results.is_empty() {
// Update result set
results.splice(i..i + 1, new_results);
modified = true;
break;
} else {
// If we couldn't resolve the nested variable, keep the original
start = end_pos + 1;
}
} else {
// Regular variable resolution
if let Some(values) = resolver.resolve_multiple(var_name) {
if !values.is_empty() {
// If there are multiple values, create a new result for each value
let mut new_results = Vec::new();
let prefix = &results[i][..actual_pos];
let suffix = &results[i][end_pos + 1..];
for value in values {
new_results.push(format!("{prefix}{value}{suffix}"));
}
results.splice(i..i + 1, new_results);
modified = true;
break;
} else {
// Variable resolved to empty, just remove the variable placeholder
let mut new_results = Vec::new();
let prefix = &results[i][..actual_pos];
let suffix = &results[i][end_pos + 1..];
new_results.push(format!("{prefix}{suffix}"));
results.splice(i..i + 1, new_results);
modified = true;
break;
}
} else {
// Variable not found, skip
start = end_pos + 1;
}
}
} else {
// No matching closing brace found, break loop
break;
}
}
if !modified {
i += 1;
}
}
results
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::Value;
use std::collections::HashMap;
#[test]
fn test_resolve_aws_variables_with_username() {
let mut context = VariableContext::new();
context.username = Some("testuser".to_string());
let resolver = VariableResolver::new(context);
let result = resolve_aws_variables("${aws:username}-bucket", &resolver);
assert_eq!(result, vec!["testuser-bucket".to_string()]);
}
#[test]
fn test_resolve_aws_variables_with_userid() {
let mut claims = HashMap::new();
claims.insert("sub".to_string(), Value::String("AIDACKCEVSQ6C2EXAMPLE".to_string()));
let mut context = VariableContext::new();
context.claims = Some(claims);
let resolver = VariableResolver::new(context);
let result = resolve_aws_variables("${aws:userid}-bucket", &resolver);
assert_eq!(result, vec!["AIDACKCEVSQ6C2EXAMPLE-bucket".to_string()]);
}
#[test]
fn test_resolve_aws_variables_with_multiple_variables() {
let mut claims = HashMap::new();
claims.insert("sub".to_string(), Value::String("AIDACKCEVSQ6C2EXAMPLE".to_string()));
let mut context = VariableContext::new();
context.claims = Some(claims);
context.username = Some("testuser".to_string());
let resolver = VariableResolver::new(context);
let result = resolve_aws_variables("${aws:username}-${aws:userid}-bucket", &resolver);
assert_eq!(result, vec!["testuser-AIDACKCEVSQ6C2EXAMPLE-bucket".to_string()]);
}
#[test]
fn test_resolve_aws_variables_no_variables() {
let context = VariableContext::new();
let resolver = VariableResolver::new(context);
let result = resolve_aws_variables("test-bucket", &resolver);
assert_eq!(result, vec!["test-bucket".to_string()]);
}
}