diff --git a/Cargo.lock b/Cargo.lock index f5118588..db11c6cc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index 203160fe..3e18ede3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/crates/e2e_test/src/common.rs b/crates/e2e_test/src/common.rs index a3cf1371..9fecad3c 100644 --- a/crates/e2e_test/src/common.rs +++ b/crates/e2e_test/src/common.rs @@ -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> { 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> { + execute_awscurl(url, "PUT", Some(body), access_key, secret_key).await +} diff --git a/crates/e2e_test/src/lib.rs b/crates/e2e_test/src/lib.rs index 8a7a7ef4..ac430785 100644 --- a/crates/e2e_test/src/lib.rs +++ b/crates/e2e_test/src/lib.rs @@ -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; diff --git a/crates/e2e_test/src/policy/README.md b/crates/e2e_test/src/policy/README.md new file mode 100644 index 00000000..16d4a4dc --- /dev/null +++ b/crates/e2e_test/src/policy/README.md @@ -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 +``` \ No newline at end of file diff --git a/crates/e2e_test/src/policy/mod.rs b/crates/e2e_test/src/policy/mod.rs new file mode 100644 index 00000000..6efa597a --- /dev/null +++ b/crates/e2e_test/src/policy/mod.rs @@ -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; diff --git a/crates/e2e_test/src/policy/policy_variables_test.rs b/crates/e2e_test/src/policy/policy_variables_test.rs new file mode 100644 index 00000000..187f355c --- /dev/null +++ b/crates/e2e_test/src/policy/policy_variables_test.rs @@ -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> { + 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> { + // 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> { + 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> { + 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> { + 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> { + // 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> { + 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> { + 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> { + // 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> { + 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> { + 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> { + // 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> { + 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> { + 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> { + 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> { + 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> { + // 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> { + // 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> { + 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> { + 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> { + // 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(()) +} diff --git a/crates/e2e_test/src/policy/test_env.rs b/crates/e2e_test/src/policy/test_env.rs new file mode 100644 index 00000000..6e7392a0 --- /dev/null +++ b/crates/e2e_test/src/policy/test_env.rs @@ -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> { + 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> { + 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); + } + } +} diff --git a/crates/e2e_test/src/policy/test_runner.rs b/crates/e2e_test/src/policy/test_runner.rs new file mode 100644 index 00000000..38989579 --- /dev/null +++ b/crates/e2e_test/src/policy/test_runner.rs @@ -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, 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, +} + +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, + 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 { + 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> { + 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> { + 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(()) +} diff --git a/crates/policy/Cargo.toml b/crates/policy/Cargo.toml index 973146ec..0c5ac2a9 100644 --- a/crates/policy/Cargo.toml +++ b/crates/policy/Cargo.toml @@ -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 diff --git a/crates/policy/src/policy.rs b/crates/policy/src/policy.rs index c6b35332..8733a859 100644 --- a/crates/policy/src/policy.rs +++ b/crates/policy/src/policy.rs @@ -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; diff --git a/crates/policy/src/policy/function.rs b/crates/policy/src/policy/function.rs index 9a847608..5c7c73eb 100644 --- a/crates/policy/src/policy/function.rs +++ b/crates/policy/src/policy/function.rs @@ -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>) -> bool { + self.evaluate_with_resolver(values, None) + } + + pub fn evaluate_with_resolver( + &self, + values: &HashMap>, + 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; } } diff --git a/crates/policy/src/policy/function/condition.rs b/crates/policy/src/policy/function/condition.rs index 85c0db36..7cbfd486 100644 --- a/crates/policy/src/policy/function/condition.rs +++ b/crates/policy/src/policy/function/condition.rs @@ -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>) -> bool { + pub fn evaluate_with_resolver( + &self, + for_all: bool, + values: &HashMap>, + 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), diff --git a/crates/policy/src/policy/function/string.rs b/crates/policy/src/policy/function/string.rs index 29e098c4..ca449c05 100644 --- a/crates/policy/src/policy/function/string.rs +++ b/crates/policy/src/policy/function/string.rs @@ -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; 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>, + 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 { - fn eval(&self, for_all: bool, ignore_case: bool, values: &HashMap>) -> bool { + fn eval( + &self, + for_all: bool, + ignore_case: bool, + values: &HashMap>, + resolver: Option<&dyn PolicyVariableResolver>, + ) -> bool { let rvalues = values // http.CanonicalHeaderKey ? .get(self.key.name().as_str()) @@ -74,8 +83,15 @@ impl FuncKeyValue { .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 { } } - fn eval_like(&self, for_all: bool, values: &HashMap>) -> bool { + fn eval_like( + &self, + for_all: bool, + values: &HashMap>, + 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::>())) .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::>())) .collect(), + None, ); result ^ negate diff --git a/crates/policy/src/policy/policy.rs b/crates/policy/src/policy/policy.rs index 334ae165..703341d2 100644 --- a/crates/policy/src/policy/policy.rs +++ b/crates/policy/src/policy/policy.rs @@ -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(()) + } } diff --git a/crates/policy/src/policy/resource.rs b/crates/policy/src/policy/resource.rs index c7415861..083f545f 100644 --- a/crates/policy/src/policy/resource.rs +++ b/crates/policy/src/policy/resource.rs @@ -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); impl ResourceSet { pub fn is_match(&self, resource: &str, conditions: &HashMap>) -> bool { + self.is_match_with_resolver(resource, conditions, None) + } + + pub fn is_match_with_resolver( + &self, + resource: &str, + conditions: &HashMap>, + 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>) -> 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>, + 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 { diff --git a/crates/policy/src/policy/statement.rs b/crates/policy/src/policy/statement.rs index 8b7218ac..c5a863dd 100644 --- a/crates/policy/src/policy/statement.rs +++ b/crates/policy/src/policy/statement.rs @@ -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) diff --git a/crates/policy/src/policy/variables.rs b/crates/policy/src/policy/variables.rs new file mode 100644 index 00000000..5278c4da --- /dev/null +++ b/crates/policy/src/policy/variables.rs @@ -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, + pub account_id: Option, + pub region: Option, + pub username: Option, + pub claims: Option>, + pub conditions: HashMap>, + pub custom_variables: HashMap, +} + +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, + /// 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 { + 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, +} + +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 { + 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> { + 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; + fn resolve_multiple(&self, variable_name: &str) -> Option> { + 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> { + 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 { + self.context.username.clone() + } + + fn resolve_userid(&self) -> Option { + // 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 { + self.context.account_id.clone() + } + + fn resolve_region(&self) -> Option { + self.context.region.clone() + } + + fn resolve_source_ip(&self) -> Option { + self.context.source_ip.clone() + } + + fn resolve_custom_variable(&self, variable_name: &str) -> Option { + 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 { + 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> { + 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 { + 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 { + // 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()]); + } +}