feat(iam,admin): prepared IAM auth, ExistingObjectTag, admin permission checks (#2315)

Signed-off-by: GatewayJ <835269233@qq.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: GatewayJ <8352692332qq.com>
This commit is contained in:
GatewayJ
2026-03-29 19:18:16 +08:00
committed by GitHub
parent 263e504c0c
commit 3366bd2464
9 changed files with 2077 additions and 438 deletions
+61 -4
View File
@@ -463,18 +463,18 @@ impl Drop for RustFSTestEnvironment {
} }
} }
/// Utility function to execute awscurl commands async fn execute_awscurl_with_service(
pub async fn execute_awscurl(
url: &str, url: &str,
method: &str, method: &str,
body: Option<&str>, body: Option<&str>,
access_key: &str, access_key: &str,
secret_key: &str, secret_key: &str,
service: &str,
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> { ) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
let mut args = vec![ let mut args = vec![
"--fail-with-body", "--fail-with-body",
"--service", "--service",
"s3", service,
"--region", "--region",
"us-east-1", "us-east-1",
"--access_key", "--access_key",
@@ -490,7 +490,64 @@ pub async fn execute_awscurl(
args.extend(&["-d", body_content]); args.extend(&["-d", body_content]);
} }
info!("Executing awscurl: {} {}", method, url); info!("Executing awscurl: {} {} (service={})", method, url, service);
let awscurl_path = awscurl_binary_path();
let output = Command::new(&awscurl_path).args(&args).output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
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();
Ok(response)
}
/// Utility function to execute awscurl commands (SigV4 service `s3` for admin APIs).
pub async fn execute_awscurl(
url: &str,
method: &str,
body: Option<&str>,
access_key: &str,
secret_key: &str,
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
execute_awscurl_with_service(url, method, body, access_key, secret_key, "s3").await
}
/// `POST` with SigV4 `--service sts` and explicit `Content-Type: application/x-www-form-urlencoded`.
///
/// RustFS `AssumeRole` is handled on `POST /` by the admin router; `is_match` requires this
/// content type so s3s routes to the custom handler instead of `Unknown operation`.
pub async fn awscurl_post_sts_form_urlencoded(
url: &str,
body: &str,
access_key: &str,
secret_key: &str,
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
let args = vec![
"--fail-with-body",
"--service",
"sts",
"--region",
"us-east-1",
"--access_key",
access_key,
"--secret_key",
secret_key,
"-H",
"Content-Type: application/x-www-form-urlencoded",
"-X",
"POST",
url,
"-d",
body,
];
info!(
"Executing awscurl: POST {} (service=sts, Content-Type=application/x-www-form-urlencoded)",
url
);
let awscurl_path = awscurl_binary_path(); let awscurl_path = awscurl_binary_path();
let output = Command::new(&awscurl_path).args(&args).output()?; let output = Command::new(&awscurl_path).args(&args).output()?;
@@ -0,0 +1,364 @@
// 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.
//! E2E: `s3:ExistingObjectTag` with **IAM identity policy**, **bucket policy**, and **STS AssumeRole
//! session policy** (`Policy` parameter) via `awscurl --service sts` with explicit
//! `Content-Type: application/x-www-form-urlencoded` on `POST /`.
use crate::common::{
RustFSTestEnvironment, awscurl_available, awscurl_delete, awscurl_post_sts_form_urlencoded, awscurl_put, init_logging,
};
use aws_sdk_s3::config::{Credentials, Region};
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::{Tag, Tagging};
use aws_sdk_s3::{Client, Config};
use serial_test::serial;
use tracing::info;
use uuid::Uuid;
fn user_client(env: &RustFSTestEnvironment, access_key: &str, secret_key: &str) -> Client {
let credentials = Credentials::new(access_key, secret_key, None, None, "e2e-existing-tag");
let config = Config::builder()
.credentials_provider(credentials)
.region(Region::new("us-east-1"))
.endpoint_url(&env.url)
.force_path_style(true)
.behavior_version_latest()
.build();
Client::from_conf(config)
}
fn sts_session_client(env: &RustFSTestEnvironment, access_key: &str, secret_key: &str, session_token: &str) -> Client {
let credentials = Credentials::new(access_key, secret_key, Some(session_token.into()), None, "e2e-sts-session");
let config = Config::builder()
.credentials_provider(credentials)
.region(Region::new("us-east-1"))
.endpoint_url(&env.url)
.force_path_style(true)
.behavior_version_latest()
.build();
Client::from_conf(config)
}
fn extract_xml_tag(xml: &str, tag: &str) -> Option<String> {
let open = format!("<{tag}>");
let close = format!("</{tag}>");
let start = xml.find(&open)? + open.len();
let end = xml[start..].find(&close)? + start;
Some(xml[start..end].to_string())
}
fn parse_assume_role_credentials(xml: &str) -> Result<(String, String, String), Box<dyn std::error::Error + Send + Sync>> {
let ak = extract_xml_tag(xml, "AccessKeyId").ok_or("missing AccessKeyId in AssumeRole response")?;
let sk = extract_xml_tag(xml, "SecretAccessKey").ok_or("missing SecretAccessKey in AssumeRole response")?;
let token = extract_xml_tag(xml, "SessionToken").ok_or("missing SessionToken in AssumeRole response")?;
Ok((ak, sk, token))
}
async fn assume_role_with_session_policy(
env: &RustFSTestEnvironment,
parent_ak: &str,
parent_sk: &str,
session_policy_json: &str,
) -> Result<(String, String, String), Box<dyn std::error::Error + Send + Sync>> {
let policy_enc = urlencoding::encode(session_policy_json);
let body = format!("Action=AssumeRole&Version=2011-06-15&DurationSeconds=3600&Policy={}", policy_enc);
let url = format!("{}/", env.url.trim_end_matches('/'));
let xml = awscurl_post_sts_form_urlencoded(&url, &body, parent_ak, parent_sk).await?;
parse_assume_role_credentials(&xml)
}
async fn admin_create_user(
env: &RustFSTestEnvironment,
username: &str,
password: &str,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let body = serde_json::json!({ "secretKey": password, "status": "enabled" }).to_string();
let url = format!("{}/rustfs/admin/v3/add-user?accessKey={}", env.url, username);
awscurl_put(&url, &body, &env.access_key, &env.secret_key).await?;
Ok(())
}
async fn admin_add_canned_policy(
env: &RustFSTestEnvironment,
policy_name: &str,
policy_json: &str,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let url = format!("{}/rustfs/admin/v3/add-canned-policy?name={}", env.url, policy_name);
awscurl_put(&url, policy_json, &env.access_key, &env.secret_key).await?;
Ok(())
}
async fn admin_attach_policy_to_user(
env: &RustFSTestEnvironment,
policy_name: &str,
username: &str,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let url = format!(
"{}/rustfs/admin/v3/set-user-or-group-policy?policyName={}&userOrGroup={}&isGroup=false",
env.url, policy_name, username
);
awscurl_put(&url, "", &env.access_key, &env.secret_key).await?;
Ok(())
}
async fn admin_remove_user(env: &RustFSTestEnvironment, username: &str) {
let url = format!("{}/rustfs/admin/v3/remove-user?accessKey={}", env.url, username);
let _ = awscurl_delete(&url, &env.access_key, &env.secret_key).await;
}
async fn admin_remove_policy(env: &RustFSTestEnvironment, policy_name: &str) {
let url = format!("{}/rustfs/admin/v3/remove-canned-policy?name={}", env.url, policy_name);
let _ = awscurl_delete(&url, &env.access_key, &env.secret_key).await;
}
async fn put_object_with_tagging_str(
client: &Client,
bucket: &str,
key: &str,
data: &[u8],
tagging: &str,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
client
.put_object()
.bucket(bucket)
.key(key)
.body(ByteStream::from(data.to_vec()))
.tagging(tagging)
.send()
.await?;
Ok(())
}
async fn put_object_tag_kv(
client: &Client,
bucket: &str,
key: &str,
tag_key: &str,
tag_value: &str,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let tag = Tag::builder()
.key(tag_key)
.value(tag_value)
.build()
.map_err(|e| format!("Tag build: {e}"))?;
let tagging = Tagging::builder()
.tag_set(tag)
.build()
.map_err(|e| format!("Tagging build: {e}"))?;
client
.put_object_tagging()
.bucket(bucket)
.key(key)
.tagging(tagging)
.send()
.await?;
Ok(())
}
async fn cleanup_bucket_and_object(admin: &Client, bucket: &str, key: &str) {
let _ = admin.delete_object().bucket(bucket).key(key).send().await;
let _ = admin.delete_bucket().bucket(bucket).send().await;
}
/// IAM identity policy: GetObject allowed only when `s3:ExistingObjectTag/security` == `public`.
#[tokio::test]
#[serial]
async fn test_e2e_iam_policy_existing_object_tag_get_object() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
if !awscurl_available() {
info!("Skipping test_e2e_iam_policy_existing_object_tag_get_object: awscurl not available");
return Ok(());
}
let suffix = Uuid::new_v4();
let user = format!("e2eiamtag-{suffix}");
let user_secret = "longSecretKeyForTest123!";
let policy_name = format!("e2e-iam-tag-pol-{suffix}");
let bucket = format!("e2e-iam-tag-bkt-{suffix}");
let key = "tagged-object.txt";
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let admin = env.create_s3_client();
admin_create_user(&env, &user, user_secret).await?;
let policy_doc = serde_json::json!({
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": ["s3:GetObject"],
"Resource": [format!("arn:aws:s3:::{}/*", bucket)],
"Condition": { "StringEquals": { "s3:ExistingObjectTag/security": "public" } }
}]
})
.to_string();
admin_add_canned_policy(&env, &policy_name, &policy_doc).await?;
admin_attach_policy_to_user(&env, &policy_name, &user).await?;
admin.create_bucket().bucket(&bucket).send().await?;
put_object_with_tagging_str(&admin, &bucket, key, b"hello-iam-tag", "security=public").await?;
let uclient = user_client(&env, &user, user_secret);
let out = uclient.get_object().bucket(&bucket).key(key).send().await?;
let _ = out.body.collect().await?;
put_object_tag_kv(&admin, &bucket, key, "security", "private").await?;
let denied = uclient.get_object().bucket(&bucket).key(key).send().await;
assert!(
denied.is_err(),
"GetObject must be denied when ExistingObjectTag no longer matches IAM policy"
);
cleanup_bucket_and_object(&admin, &bucket, key).await;
admin_remove_user(&env, &user).await;
admin_remove_policy(&env, &policy_name).await;
info!("test_e2e_iam_policy_existing_object_tag_get_object passed");
Ok(())
}
/// Bucket policy: same `ExistingObjectTag` condition; user has no canned IAM policy attached.
#[tokio::test]
#[serial]
async fn test_e2e_bucket_policy_existing_object_tag_get_object() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
if !awscurl_available() {
info!("Skipping test_e2e_bucket_policy_existing_object_tag_get_object: awscurl not available");
return Ok(());
}
let suffix = Uuid::new_v4();
let user = format!("e2ebptag-{suffix}");
let user_secret = "longSecretKeyForTest456!";
let bucket = format!("e2e-bp-tag-bkt-{suffix}");
let key = "obj.txt";
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let admin = env.create_s3_client();
admin_create_user(&env, &user, user_secret).await?;
admin.create_bucket().bucket(&bucket).send().await?;
let deny_before = user_client(&env, &user, user_secret)
.get_object()
.bucket(&bucket)
.key(key)
.send()
.await;
assert!(deny_before.is_err(), "without bucket policy, user must be denied");
let bp = serde_json::json!({
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": { "AWS": [user.clone()] },
"Action": ["s3:GetObject"],
"Resource": [format!("arn:aws:s3:::{}/*", bucket)],
"Condition": { "StringEquals": { "s3:ExistingObjectTag/security": "public" } }
}]
})
.to_string();
admin.put_bucket_policy().bucket(&bucket).policy(&bp).send().await?;
put_object_with_tagging_str(&admin, &bucket, key, b"data", "security=public").await?;
let uclient = user_client(&env, &user, user_secret);
let ok = uclient.get_object().bucket(&bucket).key(key).send().await?;
let _ = ok.body.collect().await?;
put_object_tag_kv(&admin, &bucket, key, "security", "private").await?;
let denied = uclient.get_object().bucket(&bucket).key(key).send().await;
assert!(denied.is_err(), "GetObject must fail when tag no longer satisfies bucket policy");
cleanup_bucket_and_object(&admin, &bucket, key).await;
admin_remove_user(&env, &user).await;
info!("test_e2e_bucket_policy_existing_object_tag_get_object passed");
Ok(())
}
/// STS `AssumeRole` with inline `Policy` (session policy): GetObject only when `ExistingObjectTag/security` is `public`.
#[tokio::test]
#[serial]
async fn test_e2e_sts_assume_role_session_policy_existing_object_tag() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
if !awscurl_available() {
info!("Skipping test_e2e_sts_assume_role_session_policy_existing_object_tag: awscurl not available");
return Ok(());
}
let suffix = Uuid::new_v4();
let parent = format!("e2e-sts-par-{suffix}");
let parent_secret = "longSecretKeyForParentSts99!";
let policy_readwrite = format!("e2e-sts-rw-{suffix}");
let bucket = format!("e2e-sts-tag-bkt-{suffix}");
let key = "sts-obj.txt";
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let admin = env.create_s3_client();
admin_create_user(&env, &parent, parent_secret).await?;
let rw = serde_json::to_string(&serde_json::json!({
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": ["s3:*"],
"Resource": ["arn:aws:s3:::*"]
}]
}))?;
admin_add_canned_policy(&env, &policy_readwrite, &rw).await?;
admin_attach_policy_to_user(&env, &policy_readwrite, &parent).await?;
let parent_client = user_client(&env, &parent, parent_secret);
parent_client.create_bucket().bucket(&bucket).send().await?;
put_object_with_tagging_str(&parent_client, &bucket, key, b"sts-e2e-data", "security=public").await?;
let session_policy = serde_json::json!({
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": ["s3:GetObject"],
"Resource": [format!("arn:aws:s3:::{}/*", bucket)],
"Condition": { "StringEquals": { "s3:ExistingObjectTag/security": "public" } }
}]
})
.to_string();
let (ak, sk, token) = assume_role_with_session_policy(&env, &parent, parent_secret, &session_policy).await?;
let session_client = sts_session_client(&env, &ak, &sk, &token);
let ok = session_client.get_object().bucket(&bucket).key(key).send().await?;
let _ = ok.body.collect().await?;
put_object_tag_kv(&parent_client, &bucket, key, "security", "private").await?;
let denied = session_client.get_object().bucket(&bucket).key(key).send().await;
assert!(
denied.is_err(),
"session policy must deny GetObject when ExistingObjectTag no longer matches"
);
cleanup_bucket_and_object(&admin, &bucket, key).await;
admin_remove_user(&env, &parent).await;
admin_remove_policy(&env, &policy_readwrite).await;
info!("test_e2e_sts_assume_role_session_policy_existing_object_tag passed");
Ok(())
}
+4
View File
@@ -40,6 +40,10 @@ mod quota_test;
#[cfg(test)] #[cfg(test)]
mod bucket_policy_check_test; mod bucket_policy_check_test;
/// IAM / bucket / STS session policy with `s3:ExistingObjectTag` conditions (E2E).
#[cfg(test)]
mod existing_object_tag_policy_test;
// Regression tests for Issue #2036: anonymous access with PublicAccessBlock // Regression tests for Issue #2036: anonymous access with PublicAccessBlock
#[cfg(test)] #[cfg(test)]
mod anonymous_access_test; mod anonymous_access_test;
@@ -14,7 +14,7 @@
//! Tests for AWS IAM policy variables with single-value, multi-value, and nested scenarios //! Tests for AWS IAM policy variables with single-value, multi-value, and nested scenarios
use crate::common::{awscurl_put, init_logging}; use crate::common::{awscurl_delete, awscurl_put, init_logging};
use crate::policy::test_env::PolicyTestEnvironment; use crate::policy::test_env::PolicyTestEnvironment;
use aws_sdk_s3::primitives::ByteStream; use aws_sdk_s3::primitives::ByteStream;
use serial_test::serial; use serial_test::serial;
@@ -113,11 +113,11 @@ async fn cleanup_user_and_policy(env: &PolicyTestEnvironment, username: &str, po
// Remove user // Remove user
let remove_user_url = format!("{}/rustfs/admin/v3/remove-user?accessKey={}", env.url, username); 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; let _ = awscurl_delete(&remove_user_url, &env.access_key, &env.secret_key).await;
// Remove policy // Remove policy
let remove_policy_url = format!("{}/rustfs/admin/v3/remove-canned-policy?name={}", env.url, policy_name); 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; let _ = awscurl_delete(&remove_policy_url, &env.access_key, &env.secret_key).await;
} }
/// Test AWS policy variables with single-value scenarios /// Test AWS policy variables with single-value scenarios
+621 -160
View File
@@ -35,7 +35,7 @@ use rustfs_policy::auth::{
}; };
use rustfs_policy::policy::Args; use rustfs_policy::policy::Args;
use rustfs_policy::policy::opa; use rustfs_policy::policy::opa;
use rustfs_policy::policy::{Policy, PolicyDoc, iam_policy_claim_name_sa}; use rustfs_policy::policy::{Policy, PolicyDoc, iam_policy_claim_name_sa, policy_needs_existing_object_tag_for_args};
use serde_json::Value; use serde_json::Value;
use serde_json::json; use serde_json::json;
use std::collections::HashMap; use std::collections::HashMap;
@@ -65,6 +65,78 @@ pub struct IamSys<T> {
roles_map: HashMap<ARN, String>, roles_map: HashMap<ARN, String>,
} }
#[derive(Clone)]
enum PreparedSessionPolicy {
None,
DenyAll,
Policy(Policy),
}
#[derive(Clone, Copy)]
enum PreparedServicePolicyMode {
Inherited,
SessionBound,
}
#[derive(Clone)]
enum PreparedIamMode {
Opa,
Owner,
Deny,
Regular {
combined_policy: Policy,
},
Sts {
is_owner: bool,
combined_policy: Policy,
session_policy: PreparedSessionPolicy,
},
ServiceAccount {
is_owner: bool,
parent_user: String,
combined_policy: Policy,
mode: PreparedServicePolicyMode,
session_policy: PreparedSessionPolicy,
},
}
#[derive(Clone)]
pub struct PreparedIamAuth {
pub needs_existing_object_tag: bool,
mode: PreparedIamMode,
}
impl PreparedIamAuth {
/// Evaluate whether the already-prepared IAM context needs ExistingObjectTag
/// conditions for the provided request args.
pub async fn needs_existing_object_tag_for_args(&self, args: &Args<'_>) -> bool {
match &self.mode {
PreparedIamMode::Opa | PreparedIamMode::Owner | PreparedIamMode::Deny => false,
PreparedIamMode::Regular { combined_policy } => {
policy_needs_existing_object_tag_for_args(combined_policy, args).await
}
PreparedIamMode::Sts {
combined_policy,
session_policy,
..
} => {
policy_needs_existing_object_tag_for_args(combined_policy, args).await
|| prepared_session_policy_needs_existing_object_tag_for_args(session_policy, args).await
}
PreparedIamMode::ServiceAccount {
combined_policy,
mode,
session_policy,
..
} => {
policy_needs_existing_object_tag_for_args(combined_policy, args).await
|| matches!(mode, PreparedServicePolicyMode::SessionBound)
&& prepared_session_policy_needs_existing_object_tag_for_args(session_policy, args).await
}
}
}
}
impl<T: Store> IamSys<T> { impl<T: Store> IamSys<T> {
/// Create a new IamSys instance with the given IamCache store /// Create a new IamSys instance with the given IamCache store
/// ///
@@ -740,14 +812,150 @@ impl<T: Store> IamSys<T> {
self.store.policy_db_get(name, groups).await self.store.policy_db_get(name, groups).await
} }
pub async fn is_allowed_sts(&self, args: &Args<'_>, parent_user: &str) -> bool { fn is_safe_claim_policy_name(policy: &str) -> bool {
!policy.is_empty() && policy.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
}
/// Compatibility wrapper for service-account authorization entry points.
/// The canonical evaluation path is `prepare_service_account_auth + eval_prepared`.
pub async fn is_allowed_service_account(&self, args: &Args<'_>, parent_user: &str) -> bool {
let prepared = self.prepare_service_account_auth(args, parent_user).await;
self.eval_prepared(&prepared, args).await
}
pub async fn get_combined_policy(&self, policies: &[String]) -> Policy {
self.store.merge_policies(&policies.join(",")).await.1
}
/// Prepare IAM authorization context once so callers can:
/// 1) know whether policy evaluation may need `s3:ExistingObjectTag`, and
/// 2) evaluate with final conditions without re-merging identity policies.
pub async fn prepare_auth(&self, args: &Args<'_>) -> PreparedIamAuth {
if args.is_owner {
return PreparedIamAuth {
needs_existing_object_tag: false,
mode: PreparedIamMode::Owner,
};
}
if Self::get_policy_plugin_client().await.is_some() {
return PreparedIamAuth {
needs_existing_object_tag: false,
mode: PreparedIamMode::Opa,
};
}
let Ok((is_temp, parent_user)) = self.is_temp_user(args.account).await else {
return PreparedIamAuth {
needs_existing_object_tag: false,
mode: PreparedIamMode::Deny,
};
};
if is_temp {
return self.prepare_sts_auth(args, &parent_user).await;
}
let Ok((is_svc, parent_user)) = self.is_service_account(args.account).await else {
return PreparedIamAuth {
needs_existing_object_tag: false,
mode: PreparedIamMode::Deny,
};
};
if is_svc {
return self.prepare_service_account_auth(args, &parent_user).await;
}
self.prepare_regular_auth(args).await
}
pub async fn eval_prepared(&self, prepared: &PreparedIamAuth, args: &Args<'_>) -> bool {
match &prepared.mode {
PreparedIamMode::Opa => {
let Some(opa_enable) = Self::get_policy_plugin_client().await else {
tracing::warn!("eval_prepared: OPA mode requested but plugin is unavailable");
return false;
};
opa_enable.is_allowed(args).await
}
PreparedIamMode::Owner => true,
PreparedIamMode::Deny => false,
PreparedIamMode::Regular { combined_policy } => combined_policy.is_allowed(args).await,
PreparedIamMode::Sts {
is_owner,
combined_policy,
session_policy,
} => {
let session_ok = evaluate_prepared_session_policy(session_policy, args).await;
if let Some(ok) = session_ok {
return ok && (*is_owner || combined_policy.is_allowed(args).await);
}
*is_owner || combined_policy.is_allowed(args).await
}
PreparedIamMode::ServiceAccount {
is_owner,
parent_user,
combined_policy,
mode,
session_policy,
} => {
let mut parent_args = args.clone();
parent_args.account = parent_user;
let parent_allowed = *is_owner || combined_policy.is_allowed(&parent_args).await;
match mode {
PreparedServicePolicyMode::Inherited => parent_allowed,
PreparedServicePolicyMode::SessionBound => {
let session_ok = evaluate_prepared_session_policy(session_policy, args).await;
if let Some(ok) = session_ok {
return ok && parent_allowed;
}
parent_allowed
}
}
}
}
}
async fn prepare_regular_auth(&self, args: &Args<'_>) -> PreparedIamAuth {
let Ok(policies) = self.policy_db_get(args.account, args.groups).await else {
return PreparedIamAuth {
needs_existing_object_tag: false,
mode: PreparedIamMode::Deny,
};
};
if policies.is_empty() {
return PreparedIamAuth {
needs_existing_object_tag: false,
mode: PreparedIamMode::Deny,
};
}
let combined_policy = self.get_combined_policy(&policies).await;
PreparedIamAuth {
needs_existing_object_tag: policy_needs_existing_object_tag_for_args(&combined_policy, args).await,
mode: PreparedIamMode::Regular { combined_policy },
}
}
pub(crate) async fn prepare_sts_auth(&self, args: &Args<'_>, parent_user: &str) -> PreparedIamAuth {
let is_owner = matches!(get_global_action_cred(), Some(cred) if cred.access_key == parent_user); let is_owner = matches!(get_global_action_cred(), Some(cred) if cred.access_key == parent_user);
let role_arn = args.get_role_arn(); let role_arn = args.get_role_arn();
let (effective_groups, groups_source, policies) = if is_owner { let (effective_groups, groups_source, policies) = if is_owner {
(None, "owner", Vec::new()) (None, "owner", Vec::new())
} else if let Some(arn_str) = role_arn { } else if let Some(arn_str) = role_arn {
let Ok(arn) = ARN::parse(arn_str) else { return false }; let Ok(arn) = ARN::parse(arn_str) else {
tracing::warn!(
parent_user = %parent_user,
role_arn = %arn_str,
"prepare_sts_auth: invalid role ARN in STS claims"
);
return PreparedIamAuth {
needs_existing_object_tag: false,
mode: PreparedIamMode::Deny,
};
};
let p = MappedPolicy::new(self.roles_map.get(&arn).map_or_else(String::default, |v| v.clone()).as_str()).to_slice(); let p = MappedPolicy::new(self.roles_map.get(&arn).map_or_else(String::default, |v| v.clone()).as_str()).to_slice();
(None, "role", p) (None, "role", p)
} else { } else {
@@ -758,7 +966,7 @@ impl<T: Store> IamSys<T> {
None => { None => {
tracing::warn!( tracing::warn!(
parent_user = %parent_user, parent_user = %parent_user,
"is_allowed_sts: groups fallback failed parent user not found; policy evaluation will use no groups" "prepare_sts_auth: groups fallback failed, parent user not found"
); );
(None, "parent_user_credentials") (None, "parent_user_credentials")
} }
@@ -768,9 +976,10 @@ impl<T: Store> IamSys<T> {
(effective_groups, groups_source, p) (effective_groups, groups_source, p)
}; };
let mut combined_policy = Policy::default();
if !is_owner && policies.is_empty() { if !is_owner && policies.is_empty() {
// For OIDC/STS users, policies may be specified in JWT claims rather than IAM DB. // For OIDC/STS users, policies may be specified in JWT claims rather than IAM DB.
// Resolve claim-based policy names against built-in default policies.
if let Some(claim_policies) = args.claims.get("policy").and_then(|v| v.as_str()) { if let Some(claim_policies) = args.claims.get("policy").and_then(|v| v.as_str()) {
use rustfs_policy::policy::default::DEFAULT_POLICIES; use rustfs_policy::policy::default::DEFAULT_POLICIES;
let mut resolved = Vec::new(); let mut resolved = Vec::new();
@@ -786,162 +995,161 @@ impl<T: Store> IamSys<T> {
} }
} }
if !resolved.is_empty() { if !resolved.is_empty() {
let combined = Policy::merge_policies(resolved); combined_policy = Policy::merge_policies(resolved);
let (has_session_policy, is_allowed_sp) = is_allowed_by_session_policy(args); } else if args.deny_only {
if has_session_policy { combined_policy = Policy::default();
return is_allowed_sp && combined.is_allowed(args).await; } else {
} return PreparedIamAuth {
return combined.is_allowed(args).await; needs_existing_object_tag: false,
mode: PreparedIamMode::Deny,
};
} }
} else if args.deny_only {
combined_policy = Policy::default();
} else {
return PreparedIamAuth {
needs_existing_object_tag: false,
mode: PreparedIamMode::Deny,
};
} }
} else if !is_owner {
if args.deny_only { let (a, c) = self.store.merge_policies(&policies.join(",")).await;
let combined_policy = Policy::default(); if a.is_empty() {
let (has_session_policy, is_allowed_sp) = is_allowed_by_session_policy(args); if args.deny_only {
if has_session_policy { combined_policy = Policy::default();
return is_allowed_sp && combined_policy.is_allowed(args).await; } else {
return PreparedIamAuth {
needs_existing_object_tag: false,
mode: PreparedIamMode::Deny,
};
} }
return combined_policy.is_allowed(args).await; } else {
combined_policy = c;
} }
return false;
} }
let combined_policy = { let session_policy = prepare_session_policy(args, false);
if is_owner {
Policy::default()
} else {
let (a, c) = self.store.merge_policies(&policies.join(",")).await;
if a.is_empty() {
if args.deny_only {
Policy::default()
} else {
return false;
}
} else {
c
}
}
};
let (has_session_policy, is_allowed_sp) = is_allowed_by_session_policy(args);
tracing::debug!( tracing::debug!(
"is_allowed_sts: action={:?}, has_session_policy={}, is_allowed_sp={}, is_owner={}, parent_user={}, groups_source={}, effective_groups={:?}", "prepare_sts_auth: action={:?}, is_owner={}, parent_user={}, groups_source={}, effective_groups={:?}",
args.action, args.action,
has_session_policy,
is_allowed_sp,
is_owner, is_owner,
parent_user, parent_user,
groups_source, groups_source,
effective_groups effective_groups
); );
if has_session_policy { PreparedIamAuth {
return is_allowed_sp && (is_owner || combined_policy.is_allowed(args).await); needs_existing_object_tag: policy_needs_existing_object_tag_for_args(&combined_policy, args).await
|| prepared_session_policy_needs_existing_object_tag_for_args(&session_policy, args).await,
mode: PreparedIamMode::Sts {
is_owner,
combined_policy,
session_policy,
},
} }
is_owner || combined_policy.is_allowed(args).await
} }
fn is_safe_claim_policy_name(policy: &str) -> bool { async fn prepare_service_account_auth(&self, args: &Args<'_>, parent_user: &str) -> PreparedIamAuth {
!policy.is_empty() && policy.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
}
pub async fn is_allowed_service_account(&self, args: &Args<'_>, parent_user: &str) -> bool {
let Some(p) = args.claims.get("parent") else { let Some(p) = args.claims.get("parent") else {
return false; return PreparedIamAuth {
needs_existing_object_tag: false,
mode: PreparedIamMode::Deny,
};
}; };
if p.as_str() != Some(parent_user) { if p.as_str() != Some(parent_user) {
return false; return PreparedIamAuth {
needs_existing_object_tag: false,
mode: PreparedIamMode::Deny,
};
} }
let is_owner = matches!(get_global_action_cred(), Some(cred) if cred.access_key == parent_user); let is_owner = matches!(get_global_action_cred(), Some(cred) if cred.access_key == parent_user);
let role_arn = args.get_role_arn(); let role_arn = args.get_role_arn();
let svc_policies = { let svc_policies = if is_owner {
if is_owner { Vec::new()
Vec::new() } else if role_arn.is_some() {
} else if role_arn.is_some() { let Ok(arn) = ARN::parse(role_arn.unwrap_or_default()) else {
let Ok(arn) = ARN::parse(role_arn.unwrap_or_default()) else { return false }; tracing::warn!(
MappedPolicy::new(self.roles_map.get(&arn).map_or_else(String::default, |v| v.clone()).as_str()).to_slice() parent_user = %parent_user,
} else { role_arn = ?role_arn,
let Ok(p) = self.policy_db_get(parent_user, args.groups).await else { return false }; "prepare_service_account_auth: invalid role ARN in service account claims"
p );
} return PreparedIamAuth {
needs_existing_object_tag: false,
mode: PreparedIamMode::Deny,
};
};
MappedPolicy::new(self.roles_map.get(&arn).map_or_else(String::default, |v| v.clone()).as_str()).to_slice()
} else {
let Ok(policies) = self.policy_db_get(parent_user, args.groups).await else {
return PreparedIamAuth {
needs_existing_object_tag: false,
mode: PreparedIamMode::Deny,
};
};
policies
}; };
if !is_owner && svc_policies.is_empty() { if !is_owner && svc_policies.is_empty() {
return false; return PreparedIamAuth {
needs_existing_object_tag: false,
mode: PreparedIamMode::Deny,
};
} }
let combined_policy = { let combined_policy = if is_owner {
if is_owner { Policy::default()
Policy::default() } else {
} else { let (a, c) = self.store.merge_policies(&svc_policies.join(",")).await;
let (a, c) = self.store.merge_policies(&svc_policies.join(",")).await; if a.is_empty() {
if a.is_empty() { return PreparedIamAuth {
return false; needs_existing_object_tag: false,
} mode: PreparedIamMode::Deny,
c };
} }
c
}; };
let mut parent_args = args.clone();
parent_args.account = parent_user;
let Some(sa) = args.claims.get(&iam_policy_claim_name_sa()) else { let Some(sa) = args.claims.get(&iam_policy_claim_name_sa()) else {
return false; return PreparedIamAuth {
needs_existing_object_tag: false,
mode: PreparedIamMode::Deny,
};
}; };
let Some(sa_str) = sa.as_str() else { let Some(sa_str) = sa.as_str() else {
return false; return PreparedIamAuth {
needs_existing_object_tag: false,
mode: PreparedIamMode::Deny,
};
}; };
if sa_str == INHERITED_POLICY_TYPE { let mode = if sa_str == INHERITED_POLICY_TYPE {
return is_owner || combined_policy.is_allowed(&parent_args).await; PreparedServicePolicyMode::Inherited
} else {
PreparedServicePolicyMode::SessionBound
};
let session_policy = prepare_session_policy(args, true);
let needs_existing_object_tag = policy_needs_existing_object_tag_for_args(&combined_policy, args).await
|| matches!(mode, PreparedServicePolicyMode::SessionBound)
&& prepared_session_policy_needs_existing_object_tag_for_args(&session_policy, args).await;
PreparedIamAuth {
needs_existing_object_tag,
mode: PreparedIamMode::ServiceAccount {
is_owner,
parent_user: parent_user.to_string(),
combined_policy,
mode,
session_policy,
},
} }
let (has_session_policy, is_allowed_sp) = is_allowed_by_session_policy_for_service_account(args);
if has_session_policy {
return is_allowed_sp && (is_owner || combined_policy.is_allowed(&parent_args).await);
}
is_owner || combined_policy.is_allowed(&parent_args).await
}
pub async fn get_combined_policy(&self, policies: &[String]) -> Policy {
self.store.merge_policies(&policies.join(",")).await.1
} }
pub async fn is_allowed(&self, args: &Args<'_>) -> bool { pub async fn is_allowed(&self, args: &Args<'_>) -> bool {
if args.is_owner { let prepared = self.prepare_auth(args).await;
return true; self.eval_prepared(&prepared, args).await
}
let opa_enable = Self::get_policy_plugin_client().await;
if let Some(opa_enable) = opa_enable {
return opa_enable.is_allowed(args).await;
}
let Ok((is_temp, parent_user)) = self.is_temp_user(args.account).await else { return false };
if is_temp {
return self.is_allowed_sts(args, &parent_user).await;
}
let Ok((is_svc, parent_user)) = self.is_service_account(args.account).await else { return false };
if is_svc {
return self.is_allowed_service_account(args, &parent_user).await;
}
let Ok(policies) = self.policy_db_get(args.account, args.groups).await else { return false };
if policies.is_empty() {
return false;
}
self.get_combined_policy(&policies).await.is_allowed(args).await
} }
/// Check if the underlying store is ready /// Check if the underlying store is ready
@@ -950,55 +1158,56 @@ impl<T: Store> IamSys<T> {
} }
} }
fn is_allowed_by_session_policy(args: &Args<'_>) -> (bool, bool) { async fn prepared_session_policy_needs_existing_object_tag_for_args(policy: &PreparedSessionPolicy, args: &Args<'_>) -> bool {
let Some(policy) = args.claims.get(SESSION_POLICY_NAME_EXTRACTED) else { match policy {
return (false, false); PreparedSessionPolicy::Policy(p) => policy_needs_existing_object_tag_for_args(p, args).await,
}; PreparedSessionPolicy::None | PreparedSessionPolicy::DenyAll => false,
let has_session_policy = true;
let Some(policy_str) = policy.as_str() else {
return (has_session_policy, false);
};
let Ok(sub_policy) = Policy::parse_config(policy_str.as_bytes()) else {
return (has_session_policy, false);
};
if sub_policy.version.is_empty() {
return (has_session_policy, false);
} }
let mut session_policy_args = args.clone();
session_policy_args.is_owner = false;
(has_session_policy, pollster::block_on(sub_policy.is_allowed(&session_policy_args)))
} }
fn is_allowed_by_session_policy_for_service_account(args: &Args<'_>) -> (bool, bool) { fn prepare_session_policy(args: &Args<'_>, empty_is_none: bool) -> PreparedSessionPolicy {
let Some(policy) = args.claims.get(SESSION_POLICY_NAME_EXTRACTED) else { let Some(policy_str) = extract_session_policy_text(args.claims) else {
return (false, false); return PreparedSessionPolicy::None;
};
let mut has_session_policy = true;
let Some(policy_str) = policy.as_str() else {
return (has_session_policy, false);
}; };
let Ok(sub_policy) = Policy::parse_config(policy_str.as_bytes()) else { let Ok(sub_policy) = Policy::parse_config(policy_str.as_bytes()) else {
return (has_session_policy, false); return PreparedSessionPolicy::DenyAll;
}; };
if sub_policy.version.is_empty() && sub_policy.statements.is_empty() && sub_policy.id.is_empty() { if empty_is_none {
has_session_policy = false; if sub_policy.version.is_empty() && sub_policy.statements.is_empty() && sub_policy.id.is_empty() {
return (has_session_policy, false); return PreparedSessionPolicy::None;
}
return PreparedSessionPolicy::Policy(sub_policy);
} }
let mut session_policy_args = args.clone(); if sub_policy.version.is_empty() {
session_policy_args.is_owner = false; return PreparedSessionPolicy::DenyAll;
}
(has_session_policy, pollster::block_on(sub_policy.is_allowed(&session_policy_args))) PreparedSessionPolicy::Policy(sub_policy)
}
fn extract_session_policy_text(claims: &HashMap<String, Value>) -> Option<String> {
if let Some(policy_str) = claims.get(SESSION_POLICY_NAME_EXTRACTED).and_then(|v| v.as_str()) {
return Some(policy_str.to_string());
}
let encoded = claims.get(SESSION_POLICY_NAME).and_then(|v| v.as_str())?;
let bytes = base64_simd::URL_SAFE_NO_PAD.decode_to_vec(encoded.as_bytes()).ok()?;
String::from_utf8(bytes).ok()
}
async fn evaluate_prepared_session_policy(policy: &PreparedSessionPolicy, args: &Args<'_>) -> Option<bool> {
match policy {
PreparedSessionPolicy::None => None,
PreparedSessionPolicy::DenyAll => Some(false),
PreparedSessionPolicy::Policy(p) => {
let mut session_policy_args = args.clone();
session_policy_args.is_owner = false;
Some(p.is_allowed(&session_policy_args).await)
}
}
} }
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
@@ -1050,6 +1259,7 @@ mod tests {
use rustfs_policy::auth::UserIdentity; use rustfs_policy::auth::UserIdentity;
use rustfs_policy::policy::Args; use rustfs_policy::policy::Args;
use rustfs_policy::policy::action::{Action, AdminAction, S3Action}; use rustfs_policy::policy::action::{Action, AdminAction, S3Action};
use rustfs_policy::policy::policy_uses_existing_object_tag_conditions;
use serde_json::Value; use serde_json::Value;
use std::collections::HashMap; use std::collections::HashMap;
use time::OffsetDateTime; use time::OffsetDateTime;
@@ -1301,7 +1511,8 @@ mod tests {
deny_only: false, deny_only: false,
}; };
let allowed = iam_sys.is_allowed_sts(&args, parent_user).await; let prepared = iam_sys.prepare_sts_auth(&args, parent_user).await;
let allowed = iam_sys.eval_prepared(&prepared, &args).await;
assert!( assert!(
allowed, allowed,
"STS temp credentials with no groups in args should still be allowed via parent user's group policy (readwrite)" "STS temp credentials with no groups in args should still be allowed via parent user's group policy (readwrite)"
@@ -1342,7 +1553,8 @@ mod tests {
deny_only: true, deny_only: true,
}; };
let allowed = iam_sys.is_allowed_sts(&args, parent_user).await; let prepared = iam_sys.prepare_sts_auth(&args, parent_user).await;
let allowed = iam_sys.eval_prepared(&prepared, &args).await;
assert!( assert!(
!allowed, !allowed,
"session policy Deny must be evaluated even when IAM policies are empty and deny_only is set" "session policy Deny must be evaluated even when IAM policies are empty and deny_only is set"
@@ -1381,7 +1593,8 @@ mod tests {
deny_only: true, deny_only: true,
}; };
let allowed = iam_sys.is_allowed_sts(&args, parent_user).await; let prepared = iam_sys.prepare_sts_auth(&args, parent_user).await;
let allowed = iam_sys.eval_prepared(&prepared, &args).await;
assert!( assert!(
allowed, allowed,
"deny_only with no matching Deny in session policy should still allow self-service-style checks" "deny_only with no matching Deny in session policy should still allow self-service-style checks"
@@ -1412,4 +1625,252 @@ mod tests {
"regular user mapped policy must be written to user_policies for bucket user listing" "regular user mapped policy must be written to user_policies for bucket user listing"
); );
} }
#[tokio::test]
async fn test_prepare_auth_eval_matches_prepare_sts_auth_for_parent_policy_fallback() {
let store = StsTestMockStore { empty_policies: false };
let cache_manager = IamCache::new(store).await;
let iam_sys = IamSys::new(cache_manager);
let parent_user = "sts-fallback-test-parent";
let claims = HashMap::new();
let groups: Option<Vec<String>> = None;
let args = Args {
account: parent_user,
groups: &groups,
action: Action::S3Action(S3Action::ListBucketAction),
bucket: "mybucket",
conditions: &HashMap::new(),
is_owner: false,
object: "",
claims: &claims,
deny_only: false,
};
let sts_prepared = iam_sys.prepare_sts_auth(&args, parent_user).await;
let sts_eval = iam_sys.eval_prepared(&sts_prepared, &args).await;
let prepared = iam_sys.prepare_auth(&args).await;
let eval = iam_sys.eval_prepared(&prepared, &args).await;
assert_eq!(sts_eval, eval, "prepare_auth must match explicit STS preparation for this identity");
}
#[tokio::test]
async fn test_prepare_auth_detects_existing_object_tag_in_session_policy() {
let store = StsTestMockStore { empty_policies: true };
let cache_manager = IamCache::new(store).await;
let iam_sys = IamSys::new(cache_manager);
let sts_access_key = "sts-session-tag-test-user";
let sts_user = UserIdentity::from(Credentials {
access_key: sts_access_key.to_string(),
secret_key: "longenoughsecret".to_string(),
session_token: "sts-token".to_string(),
status: ACCOUNT_ON.to_string(),
parent_user: "sts-empty-parent-policy-test".to_string(),
..Default::default()
});
Cache::add_or_update(&iam_sys.store.cache.sts_accounts, sts_access_key, &sts_user, OffsetDateTime::now_utc());
let mut claims = HashMap::new();
claims.insert(
SESSION_POLICY_NAME_EXTRACTED.to_string(),
Value::String(
r#"{
"Version":"2012-10-17",
"Statement":[{"Effect":"Allow","Action":["s3:GetObject"],"Resource":["arn:aws:s3:::bucket/*"],"Condition":{"StringEquals":{"s3:ExistingObjectTag/security":"public"}}}]
}"#
.to_string(),
),
);
let groups: Option<Vec<String>> = None;
let args = Args {
account: sts_access_key,
groups: &groups,
action: Action::S3Action(S3Action::GetObjectAction),
bucket: "bucket",
conditions: &HashMap::new(),
is_owner: false,
object: "obj",
claims: &claims,
deny_only: true,
};
let prepared = iam_sys.prepare_auth(&args).await;
assert!(
prepared.needs_existing_object_tag,
"session policy with ExistingObjectTag must request object tag loading"
);
}
#[test]
fn test_policy_uses_existing_object_tag_matches_condition_keys_only() {
let with_value_only = Policy::parse_config(
br#"{
"Version":"2012-10-17",
"Statement":[{
"Effect":"Allow",
"Action":["s3:GetObject"],
"Resource":["arn:aws:s3:::bucket/*"],
"Condition":{"StringEquals":{"s3:prefix":"ExistingObjectTag/security"}}
}]
}"#,
)
.expect("policy with value-only ExistingObjectTag text should parse");
assert!(
!policy_uses_existing_object_tag_conditions(&with_value_only),
"ExistingObjectTag text in values should not trigger tag dependency"
);
let with_condition_key = Policy::parse_config(
br#"{
"Version":"2012-10-17",
"Statement":[{
"Effect":"Allow",
"Action":["s3:GetObject"],
"Resource":["arn:aws:s3:::bucket/*"],
"Condition":{"StringEquals":{"s3:ExistingObjectTag/security":"public"}}
}]
}"#,
)
.expect("policy with ExistingObjectTag condition key should parse");
assert!(
policy_uses_existing_object_tag_conditions(&with_condition_key),
"ExistingObjectTag condition key must trigger tag dependency"
);
}
#[test]
fn test_policy_uses_existing_object_tag_when_only_secondary_action_has_tag_condition() {
let split_action_policy = Policy::parse_config(
br#"{
"Version":"2012-10-17",
"Statement":[
{
"Effect":"Allow",
"Action":["s3:DeleteObject"],
"Resource":["arn:aws:s3:::bucket/*"]
},
{
"Effect":"Allow",
"Action":["s3:DeleteObjectVersion"],
"Resource":["arn:aws:s3:::bucket/*"],
"Condition":{"StringEquals":{"s3:ExistingObjectTag/security":"public"}}
}
]
}"#,
)
.expect("split-action policy should parse");
assert!(
policy_uses_existing_object_tag_conditions(&split_action_policy),
"full merged policy must still be detectable as containing ExistingObjectTag keys"
);
}
#[tokio::test]
async fn test_prepare_auth_detects_existing_object_tag_in_encoded_session_policy() {
let store = StsTestMockStore { empty_policies: true };
let cache_manager = IamCache::new(store).await;
let iam_sys = IamSys::new(cache_manager);
let sts_access_key = "sts-session-tag-encoded-test-user";
let sts_user = UserIdentity::from(Credentials {
access_key: sts_access_key.to_string(),
secret_key: "longenoughsecret".to_string(),
session_token: "sts-token".to_string(),
status: ACCOUNT_ON.to_string(),
parent_user: "sts-empty-parent-policy-test".to_string(),
..Default::default()
});
Cache::add_or_update(&iam_sys.store.cache.sts_accounts, sts_access_key, &sts_user, OffsetDateTime::now_utc());
let session_policy_json = r#"{
"Version":"2012-10-17",
"Statement":[{"Effect":"Allow","Action":["s3:GetObject"],"Resource":["arn:aws:s3:::bucket/*"],"Condition":{"StringEquals":{"s3:ExistingObjectTag/security":"public"}}}]
}"#;
let mut claims = HashMap::new();
claims.insert(
SESSION_POLICY_NAME.to_string(),
Value::String(base64_simd::URL_SAFE_NO_PAD.encode_to_string(session_policy_json.as_bytes())),
);
let groups: Option<Vec<String>> = None;
let args = Args {
account: sts_access_key,
groups: &groups,
action: Action::S3Action(S3Action::GetObjectAction),
bucket: "bucket",
conditions: &HashMap::new(),
is_owner: false,
object: "obj",
claims: &claims,
deny_only: true,
};
let prepared = iam_sys.prepare_auth(&args).await;
assert!(
prepared.needs_existing_object_tag,
"base64 sessionPolicy with ExistingObjectTag must request object tag loading"
);
}
#[tokio::test]
async fn test_prepare_auth_service_account_inherited_ignores_session_policy_tag_hint() {
let store = StsTestMockStore { empty_policies: false };
let cache_manager = IamCache::new(store).await;
let iam_sys = IamSys::new(cache_manager);
let service_account_access_key = "svc-inherited-tag-hint-test-user";
let parent_user = "sts-fallback-test-parent";
let mut service_account_claims = HashMap::new();
service_account_claims.insert(iam_policy_claim_name_sa(), Value::String(INHERITED_POLICY_TYPE.to_string()));
let service_identity = UserIdentity::from(Credentials {
access_key: service_account_access_key.to_string(),
secret_key: "longenoughsecret".to_string(),
status: ACCOUNT_ON.to_string(),
parent_user: parent_user.to_string(),
claims: Some(service_account_claims),
..Default::default()
});
Cache::add_or_update(
&iam_sys.store.cache.users,
service_account_access_key,
&service_identity,
OffsetDateTime::now_utc(),
);
let mut request_claims = HashMap::new();
request_claims.insert("parent".to_string(), Value::String(parent_user.to_string()));
request_claims.insert(iam_policy_claim_name_sa(), Value::String(INHERITED_POLICY_TYPE.to_string()));
request_claims.insert(
SESSION_POLICY_NAME_EXTRACTED.to_string(),
Value::String(
r#"{
"Version":"2012-10-17",
"Statement":[{"Effect":"Allow","Action":["s3:GetObject"],"Resource":["arn:aws:s3:::bucket/*"],"Condition":{"StringEquals":{"s3:ExistingObjectTag/security":"public"}}}]
}"#
.to_string(),
),
);
let groups: Option<Vec<String>> = Some(vec!["testgroup".to_string()]);
let args = Args {
account: service_account_access_key,
groups: &groups,
action: Action::S3Action(S3Action::GetObjectAction),
bucket: "bucket",
conditions: &HashMap::new(),
is_owner: false,
object: "obj",
claims: &request_claims,
deny_only: false,
};
let prepared = iam_sys.prepare_auth(&args).await;
assert!(
!prepared.needs_existing_object_tag,
"inherited service account should not require object tag fetch based on session policy hint"
);
}
} }
+335 -1
View File
@@ -12,7 +12,10 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
use super::{Effect, Error as IamError, ID, Statement, action::Action, statement::BPStatement}; use super::{
Effect, Error as IamError, Functions, ID, Statement, action::Action, statement::BPStatement,
statement::variable_resolver_for_policy_args,
};
use crate::error::{Error, Result}; use crate::error::{Error, Result};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
@@ -272,6 +275,77 @@ pub fn iam_policy_claim_name_sa() -> String {
rustfs_credentials::IAM_POLICY_CLAIM_NAME_SA.to_string() rustfs_credentials::IAM_POLICY_CLAIM_NAME_SA.to_string()
} }
#[inline]
pub fn is_existing_object_tag_condition_key(key: &str) -> bool {
matches!(key, "ExistingObjectTag" | "s3:ExistingObjectTag")
|| key.starts_with("ExistingObjectTag/")
|| key.starts_with("s3:ExistingObjectTag/")
}
pub fn value_uses_existing_object_tag_condition_key(value: &Value) -> bool {
match value {
Value::Object(obj) => obj
.iter()
.any(|(key, value)| is_existing_object_tag_condition_key(key) || value_uses_existing_object_tag_condition_key(value)),
Value::Array(items) => items.iter().any(value_uses_existing_object_tag_condition_key),
_ => false,
}
}
/// True if `conditions` JSON references `s3:ExistingObjectTag` / `ExistingObjectTag/...` keys.
pub fn functions_use_existing_object_tag(conditions: &Functions) -> bool {
serde_json::to_value(conditions)
.map(|v| value_uses_existing_object_tag_condition_key(&v))
.unwrap_or(false)
}
pub fn policy_uses_existing_object_tag_conditions(policy: &Policy) -> bool {
policy
.statements
.iter()
.any(|statement| functions_use_existing_object_tag(&statement.conditions))
}
pub fn bucket_policy_uses_existing_object_tag_conditions(policy: &BucketPolicy) -> bool {
policy
.statements
.iter()
.any(|statement| functions_use_existing_object_tag(&statement.conditions))
}
/// True when at least one statement that applies to `args` may evaluate ExistingObjectTag conditions.
pub async fn policy_needs_existing_object_tag_for_args(policy: &Policy, args: &Args<'_>) -> bool {
if !policy_uses_existing_object_tag_conditions(policy) {
return false;
}
let resolver = variable_resolver_for_policy_args(args);
for statement in &policy.statements {
if !functions_use_existing_object_tag(&statement.conditions) {
continue;
}
if statement.request_reaches_condition_eval(args, &resolver).await {
return true;
}
}
false
}
/// True when at least one bucket-policy statement that applies to `args` may evaluate ExistingObjectTag conditions.
pub async fn bucket_policy_needs_existing_object_tag_for_args(policy: &BucketPolicy, args: &BucketPolicyArgs<'_>) -> bool {
if !bucket_policy_uses_existing_object_tag_conditions(policy) {
return false;
}
for statement in &policy.statements {
if !functions_use_existing_object_tag(&statement.conditions) {
continue;
}
if statement.request_reaches_condition_eval(args).await {
return true;
}
}
false
}
pub mod default { pub mod default {
use std::{collections::HashSet, sync::LazyLock}; use std::{collections::HashSet, sync::LazyLock};
@@ -1231,6 +1305,61 @@ mod test {
assert_eq!(statement["Principal"]["AWS"], "*"); assert_eq!(statement["Principal"]["AWS"], "*");
} }
#[test]
fn test_existing_object_tag_condition_helpers() {
let identity_policy = Policy::parse_config(
br#"{
"Version":"2012-10-17",
"Statement":[{
"Effect":"Allow",
"Action":["s3:GetObject"],
"Resource":["arn:aws:s3:::bucket/*"],
"Condition":{"StringEquals":{"s3:ExistingObjectTag/security":"public"}}
}]
}"#,
)
.expect("identity policy with ExistingObjectTag key should parse");
assert!(
policy_uses_existing_object_tag_conditions(&identity_policy),
"identity policy ExistingObjectTag key should be detected"
);
let identity_value_only = Policy::parse_config(
br#"{
"Version":"2012-10-17",
"Statement":[{
"Effect":"Allow",
"Action":["s3:GetObject"],
"Resource":["arn:aws:s3:::bucket/*"],
"Condition":{"StringEquals":{"s3:prefix":"ExistingObjectTag/security"}}
}]
}"#,
)
.expect("identity policy with value-only marker should parse");
assert!(
!policy_uses_existing_object_tag_conditions(&identity_value_only),
"value-only marker must not be treated as ExistingObjectTag condition key"
);
let bucket_policy: BucketPolicy = serde_json::from_str(
r#"{
"Version":"2012-10-17",
"Statement":[{
"Effect":"Allow",
"Principal":"*",
"Action":["s3:GetObject"],
"Resource":["arn:aws:s3:::bucket/*"],
"Condition":{"StringEquals":{"s3:ExistingObjectTag/security":"public"}}
}]
}"#,
)
.expect("bucket policy with ExistingObjectTag key should parse");
assert!(
bucket_policy_uses_existing_object_tag_conditions(&bucket_policy),
"bucket policy ExistingObjectTag key should be detected"
);
}
#[test] #[test]
fn test_bucket_policy_serialize_single_action_as_array() { fn test_bucket_policy_serialize_single_action_as_array() {
use crate::policy::action::{Action, ActionSet, S3Action}; use crate::policy::action::{Action, ActionSet, S3Action};
@@ -1359,4 +1488,209 @@ mod test {
Ok(()) Ok(())
} }
#[tokio::test]
async fn test_policy_needs_existing_object_tag_narrows_by_action() {
use crate::policy::Args;
use crate::policy::action::{Action, S3Action};
use std::collections::HashMap;
let split_policy = Policy::parse_config(
br#"{
"Version":"2012-10-17",
"Statement":[
{
"Effect":"Allow",
"Action":["s3:DeleteObject"],
"Resource":["arn:aws:s3:::bucket/*"]
},
{
"Effect":"Allow",
"Action":["s3:DeleteObjectVersion"],
"Resource":["arn:aws:s3:::bucket/*"],
"Condition":{"StringEquals":{"s3:ExistingObjectTag/security":"public"}}
}
]
}"#,
)
.expect("split-action policy should parse");
let groups: Option<Vec<String>> = None;
let cond = HashMap::new();
let claims = HashMap::new();
let args_get = Args {
account: "user",
groups: &groups,
action: Action::S3Action(S3Action::GetObjectAction),
bucket: "bucket",
conditions: &cond,
is_owner: false,
object: "k",
claims: &claims,
deny_only: false,
};
assert!(
!policy_needs_existing_object_tag_for_args(&split_policy, &args_get).await,
"GetObject should not match statements with DeleteObject/DeleteObjectVersion"
);
let args_del = Args {
account: "user",
groups: &groups,
action: Action::S3Action(S3Action::DeleteObjectAction),
bucket: "bucket",
conditions: &cond,
is_owner: false,
object: "k",
claims: &claims,
deny_only: false,
};
assert!(
!policy_needs_existing_object_tag_for_args(&split_policy, &args_del).await,
"DeleteObject matches only the statement without ExistingObjectTag"
);
let args_delv = Args {
account: "user",
groups: &groups,
action: Action::S3Action(S3Action::DeleteObjectVersionAction),
bucket: "bucket",
conditions: &cond,
is_owner: false,
object: "k",
claims: &claims,
deny_only: false,
};
assert!(
policy_needs_existing_object_tag_for_args(&split_policy, &args_delv).await,
"DeleteObjectVersion matches the statement with ExistingObjectTag"
);
}
#[tokio::test]
async fn test_policy_needs_existing_object_tag_narrows_by_resource() {
use crate::policy::Args;
use crate::policy::action::{Action, S3Action};
use std::collections::HashMap;
let policy = Policy::parse_config(
br#"{
"Version":"2012-10-17",
"Statement":[
{
"Effect":"Allow",
"Action":["s3:GetObject"],
"Resource":["arn:aws:s3:::bucket/private/*"],
"Condition":{"StringEquals":{"s3:ExistingObjectTag/security":"public"}}
}
]
}"#,
)
.expect("policy should parse");
let groups: Option<Vec<String>> = None;
let cond = HashMap::new();
let claims = HashMap::new();
let args_public = Args {
account: "user",
groups: &groups,
action: Action::S3Action(S3Action::GetObjectAction),
bucket: "bucket",
conditions: &cond,
is_owner: false,
object: "public/a.txt",
claims: &claims,
deny_only: false,
};
assert!(
!policy_needs_existing_object_tag_for_args(&policy, &args_public).await,
"resource mismatch should skip ExistingObjectTag fetch hint"
);
let args_private = Args {
account: "user",
groups: &groups,
action: Action::S3Action(S3Action::GetObjectAction),
bucket: "bucket",
conditions: &cond,
is_owner: false,
object: "private/a.txt",
claims: &claims,
deny_only: false,
};
assert!(
policy_needs_existing_object_tag_for_args(&policy, &args_private).await,
"resource match should keep ExistingObjectTag fetch hint"
);
}
#[tokio::test]
async fn test_bucket_policy_needs_existing_object_tag_narrows_by_principal() {
use crate::policy::BucketPolicyArgs;
use crate::policy::action::{Action, S3Action};
use std::collections::HashMap;
let bucket_policy: BucketPolicy = serde_json::from_str(
r#"{
"Version":"2012-10-17",
"Statement":[
{
"Effect":"Allow",
"Principal":{"AWS":["alice"]},
"Action":["s3:GetObject"],
"Resource":["arn:aws:s3:::bucket/private/*"],
"Condition":{"StringEquals":{"s3:ExistingObjectTag/security":"public"}}
}
]
}"#,
)
.expect("bucket policy should parse");
let groups: Option<Vec<String>> = None;
let cond = HashMap::new();
let args_bob = BucketPolicyArgs {
bucket: "bucket",
action: Action::S3Action(S3Action::GetObjectAction),
is_owner: false,
account: "bob",
groups: &groups,
conditions: &cond,
object: "private/a.txt",
};
assert!(
!bucket_policy_needs_existing_object_tag_for_args(&bucket_policy, &args_bob).await,
"principal mismatch should skip ExistingObjectTag fetch hint"
);
let args_alice_public = BucketPolicyArgs {
bucket: "bucket",
action: Action::S3Action(S3Action::GetObjectAction),
is_owner: false,
account: "alice",
groups: &groups,
conditions: &cond,
object: "public/a.txt",
};
assert!(
!bucket_policy_needs_existing_object_tag_for_args(&bucket_policy, &args_alice_public).await,
"resource mismatch should skip ExistingObjectTag fetch hint"
);
let args_alice_private = BucketPolicyArgs {
bucket: "bucket",
action: Action::S3Action(S3Action::GetObjectAction),
is_owner: false,
account: "alice",
groups: &groups,
conditions: &cond,
object: "private/a.txt",
};
assert!(
bucket_policy_needs_existing_object_tag_for_args(&bucket_policy, &args_alice_private).await,
"principal and resource match should keep ExistingObjectTag fetch hint"
);
}
} }
+96 -74
View File
@@ -38,6 +38,24 @@ pub struct Statement {
pub conditions: Functions, pub conditions: Functions,
} }
/// Builds the same [`VariableResolver`] as [`Statement::is_allowed`].
pub(crate) fn variable_resolver_for_policy_args(args: &Args<'_>) -> VariableResolver {
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()) {
parent.to_string()
} else {
args.account.to_string()
};
context.username = Some(username);
VariableResolver::new(context)
}
impl Statement { impl Statement {
fn is_kms(&self) -> bool { fn is_kms(&self) -> bool {
for act in self.actions.iter() { for act in self.actions.iter() {
@@ -69,67 +87,62 @@ impl Statement {
false false
} }
pub async fn is_allowed(&self, args: &Args<'_>) -> bool { /// Returns true when this statement would reach `conditions.evaluate_with_resolver` in
let mut context = VariableContext::new(); /// [`Statement::is_allowed`] (including the KMS shortcut path). Does not evaluate conditions.
context.claims = Some(args.claims.clone()); pub(crate) async fn request_reaches_condition_eval(&self, args: &Args<'_>, resolver: &VariableResolver) -> bool {
context.conditions = args.conditions.clone(); if (!self.actions.is_match(&args.action) && !self.actions.is_empty()) || self.not_actions.is_match(&args.action) {
context.account_id = Some(args.account.to_string()); return false;
}
let username = if let Some(parent) = args.claims.get("parent").and_then(|v| v.as_str()) { let mut resource = String::from(args.bucket);
// For temp credentials or service account credentials, username is parent_user if !args.object.is_empty() {
parent.to_string() if !args.object.starts_with('/') {
} 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;
}
let mut resource = String::from(args.bucket);
if !args.object.is_empty() {
if !args.object.starts_with('/') {
resource.push('/');
}
resource.push_str(args.object);
} else {
resource.push('/'); resource.push('/');
} }
if self.is_kms() && (resource == "/" || self.resources.is_empty()) { resource.push_str(args.object);
break 'c self.conditions.evaluate_with_resolver(args.conditions, Some(&resolver)).await; } else {
} resource.push('/');
}
if self.resources.is_empty() && self.not_resources.is_empty() && !self.is_admin() && !self.is_sts() { if self.is_kms() && (resource == "/" || self.resources.is_empty()) {
break 'c false; return true;
} }
if !self.resources.is_empty() if self.resources.is_empty() && self.not_resources.is_empty() && !self.is_admin() && !self.is_sts() {
&& !self return false;
.resources }
.is_match_with_resolver(&resource, args.conditions, Some(&resolver))
.await
&& !self.is_admin()
&& !self.is_sts()
{
break 'c false;
}
if !self.not_resources.is_empty() if !self.resources.is_empty()
&& self && !self
.not_resources .resources
.is_match_with_resolver(&resource, args.conditions, Some(&resolver)) .is_match_with_resolver(&resource, args.conditions, Some(resolver))
.await .await
&& !self.is_admin() && !self.is_admin()
&& !self.is_sts() && !self.is_sts()
{ {
return false;
}
if !self.not_resources.is_empty()
&& self
.not_resources
.is_match_with_resolver(&resource, args.conditions, Some(resolver))
.await
&& !self.is_admin()
&& !self.is_sts()
{
return false;
}
true
}
pub async fn is_allowed(&self, args: &Args<'_>) -> bool {
let resolver = variable_resolver_for_policy_args(args);
let check = 'c: {
if !self.request_reaches_condition_eval(args, &resolver).await {
break 'c false; break 'c false;
} }
@@ -207,32 +220,41 @@ pub struct BPStatement {
} }
impl BPStatement { impl BPStatement {
pub async fn is_allowed(&self, args: &BucketPolicyArgs<'_>) -> bool { /// Returns true when this statement would reach `conditions.evaluate` in [`BPStatement::is_allowed`].
let check = 'c: { pub(crate) async fn request_reaches_condition_eval(&self, args: &BucketPolicyArgs<'_>) -> bool {
if !self.principal.is_match(args.account) { if !self.principal.is_match(args.account) {
break 'c false; return false;
} }
if (!self.actions.is_match(&args.action) && !self.actions.is_empty()) || self.not_actions.is_match(&args.action) { if (!self.actions.is_match(&args.action) && !self.actions.is_empty()) || self.not_actions.is_match(&args.action) {
break 'c false; return false;
} }
let mut resource = String::from(args.bucket); let mut resource = String::from(args.bucket);
if !args.object.is_empty() { if !args.object.is_empty() {
if !args.object.starts_with('/') { if !args.object.starts_with('/') {
resource.push('/');
}
resource.push_str(args.object);
} else {
resource.push('/'); resource.push('/');
} }
if !self.resources.is_empty() && !self.resources.is_match(&resource, args.conditions).await { resource.push_str(args.object);
break 'c false; } else {
} resource.push('/');
}
if !self.not_resources.is_empty() && self.not_resources.is_match(&resource, args.conditions).await { if !self.resources.is_empty() && !self.resources.is_match(&resource, args.conditions).await {
return false;
}
if !self.not_resources.is_empty() && self.not_resources.is_match(&resource, args.conditions).await {
return false;
}
true
}
pub async fn is_allowed(&self, args: &BucketPolicyArgs<'_>) -> bool {
let check = 'c: {
if !self.request_reaches_condition_eval(args).await {
break 'c false; break 'c false;
} }
-3
View File
@@ -132,9 +132,6 @@ pub async fn validate_admin_request_with_bucket(
Err(s3_error!(AccessDenied, "Access Denied")) Err(s3_error!(AccessDenied, "Access Denied"))
} }
/// Unified authentication request handler for both UI and CLI
///
/// This function provides a single entry point for authentication,
/// Unified authentication request handler for both UI and CLI /// Unified authentication request handler for both UI and CLI
/// ///
/// This function provides a single entry point for authentication, /// This function provides a single entry point for authentication,
File diff suppressed because it is too large Load Diff