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
pub async fn execute_awscurl(
async fn execute_awscurl_with_service(
url: &str,
method: &str,
body: Option<&str>,
access_key: &str,
secret_key: &str,
service: &str,
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
let mut args = vec![
"--fail-with-body",
"--service",
"s3",
service,
"--region",
"us-east-1",
"--access_key",
@@ -490,7 +490,64 @@ pub async fn execute_awscurl(
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 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)]
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
#[cfg(test)]
mod anonymous_access_test;
@@ -14,7 +14,7 @@
//! 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 aws_sdk_s3::primitives::ByteStream;
use serial_test::serial;
@@ -113,11 +113,11 @@ async fn cleanup_user_and_policy(env: &PolicyTestEnvironment, username: &str, po
// 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;
let _ = awscurl_delete(&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;
let _ = awscurl_delete(&remove_policy_url, &env.access_key, &env.secret_key).await;
}
/// 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::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::json;
use std::collections::HashMap;
@@ -65,6 +65,78 @@ pub struct IamSys<T> {
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> {
/// 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
}
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 role_arn = args.get_role_arn();
let (effective_groups, groups_source, policies) = if is_owner {
(None, "owner", Vec::new())
} 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();
(None, "role", p)
} else {
@@ -758,7 +966,7 @@ impl<T: Store> IamSys<T> {
None => {
tracing::warn!(
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")
}
@@ -768,9 +976,10 @@ impl<T: Store> IamSys<T> {
(effective_groups, groups_source, p)
};
let mut combined_policy = Policy::default();
if !is_owner && policies.is_empty() {
// 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()) {
use rustfs_policy::policy::default::DEFAULT_POLICIES;
let mut resolved = Vec::new();
@@ -786,162 +995,161 @@ impl<T: Store> IamSys<T> {
}
}
if !resolved.is_empty() {
let combined = Policy::merge_policies(resolved);
let (has_session_policy, is_allowed_sp) = is_allowed_by_session_policy(args);
if has_session_policy {
return is_allowed_sp && combined.is_allowed(args).await;
}
return combined.is_allowed(args).await;
combined_policy = Policy::merge_policies(resolved);
} else if args.deny_only {
combined_policy = Policy::default();
} else {
return PreparedIamAuth {
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,
};
}
if args.deny_only {
let combined_policy = Policy::default();
let (has_session_policy, is_allowed_sp) = is_allowed_by_session_policy(args);
if has_session_policy {
return is_allowed_sp && combined_policy.is_allowed(args).await;
} else if !is_owner {
let (a, c) = self.store.merge_policies(&policies.join(",")).await;
if a.is_empty() {
if args.deny_only {
combined_policy = Policy::default();
} 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 = {
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);
let session_policy = prepare_session_policy(args, false);
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,
has_session_policy,
is_allowed_sp,
is_owner,
parent_user,
groups_source,
effective_groups
);
if has_session_policy {
return is_allowed_sp && (is_owner || combined_policy.is_allowed(args).await);
PreparedIamAuth {
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 {
!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 {
async fn prepare_service_account_auth(&self, args: &Args<'_>, parent_user: &str) -> PreparedIamAuth {
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) {
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 role_arn = args.get_role_arn();
let svc_policies = {
if is_owner {
Vec::new()
} else if role_arn.is_some() {
let Ok(arn) = ARN::parse(role_arn.unwrap_or_default()) else { return false };
MappedPolicy::new(self.roles_map.get(&arn).map_or_else(String::default, |v| v.clone()).as_str()).to_slice()
} else {
let Ok(p) = self.policy_db_get(parent_user, args.groups).await else { return false };
p
}
let svc_policies = if is_owner {
Vec::new()
} else if role_arn.is_some() {
let Ok(arn) = ARN::parse(role_arn.unwrap_or_default()) else {
tracing::warn!(
parent_user = %parent_user,
role_arn = ?role_arn,
"prepare_service_account_auth: invalid role ARN in service account claims"
);
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() {
return false;
return PreparedIamAuth {
needs_existing_object_tag: false,
mode: PreparedIamMode::Deny,
};
}
let combined_policy = {
if is_owner {
Policy::default()
} else {
let (a, c) = self.store.merge_policies(&svc_policies.join(",")).await;
if a.is_empty() {
return false;
}
c
let combined_policy = if is_owner {
Policy::default()
} else {
let (a, c) = self.store.merge_policies(&svc_policies.join(",")).await;
if a.is_empty() {
return PreparedIamAuth {
needs_existing_object_tag: false,
mode: PreparedIamMode::Deny,
};
}
c
};
let mut parent_args = args.clone();
parent_args.account = parent_user;
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 {
return false;
return PreparedIamAuth {
needs_existing_object_tag: false,
mode: PreparedIamMode::Deny,
};
};
if sa_str == INHERITED_POLICY_TYPE {
return is_owner || combined_policy.is_allowed(&parent_args).await;
let mode = if sa_str == INHERITED_POLICY_TYPE {
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 {
if args.is_owner {
return true;
}
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
let prepared = self.prepare_auth(args).await;
self.eval_prepared(&prepared, args).await
}
/// 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) {
let Some(policy) = args.claims.get(SESSION_POLICY_NAME_EXTRACTED) else {
return (false, 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);
async fn prepared_session_policy_needs_existing_object_tag_for_args(policy: &PreparedSessionPolicy, args: &Args<'_>) -> bool {
match policy {
PreparedSessionPolicy::Policy(p) => policy_needs_existing_object_tag_for_args(p, args).await,
PreparedSessionPolicy::None | PreparedSessionPolicy::DenyAll => 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) {
let Some(policy) = args.claims.get(SESSION_POLICY_NAME_EXTRACTED) else {
return (false, false);
};
let mut has_session_policy = true;
let Some(policy_str) = policy.as_str() else {
return (has_session_policy, false);
fn prepare_session_policy(args: &Args<'_>, empty_is_none: bool) -> PreparedSessionPolicy {
let Some(policy_str) = extract_session_policy_text(args.claims) else {
return PreparedSessionPolicy::None;
};
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() {
has_session_policy = false;
return (has_session_policy, false);
if empty_is_none {
if sub_policy.version.is_empty() && sub_policy.statements.is_empty() && sub_policy.id.is_empty() {
return PreparedSessionPolicy::None;
}
return PreparedSessionPolicy::Policy(sub_policy);
}
let mut session_policy_args = args.clone();
session_policy_args.is_owner = false;
if sub_policy.version.is_empty() {
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)]
@@ -1050,6 +1259,7 @@ mod tests {
use rustfs_policy::auth::UserIdentity;
use rustfs_policy::policy::Args;
use rustfs_policy::policy::action::{Action, AdminAction, S3Action};
use rustfs_policy::policy::policy_uses_existing_object_tag_conditions;
use serde_json::Value;
use std::collections::HashMap;
use time::OffsetDateTime;
@@ -1301,7 +1511,8 @@ mod tests {
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!(
allowed,
"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,
};
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!(
!allowed,
"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,
};
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!(
allowed,
"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"
);
}
#[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
// 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 serde::{Deserialize, Serialize};
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()
}
#[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 {
use std::{collections::HashSet, sync::LazyLock};
@@ -1231,6 +1305,61 @@ mod test {
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]
fn test_bucket_policy_serialize_single_action_as_array() {
use crate::policy::action::{Action, ActionSet, S3Action};
@@ -1359,4 +1488,209 @@ mod test {
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,
}
/// 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 {
fn is_kms(&self) -> bool {
for act in self.actions.iter() {
@@ -69,67 +87,62 @@ impl Statement {
false
}
pub async 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());
/// Returns true when this statement would reach `conditions.evaluate_with_resolver` in
/// [`Statement::is_allowed`] (including the KMS shortcut path). Does not evaluate conditions.
pub(crate) async fn request_reaches_condition_eval(&self, args: &Args<'_>, resolver: &VariableResolver) -> bool {
if (!self.actions.is_match(&args.action) && !self.actions.is_empty()) || self.not_actions.is_match(&args.action) {
return false;
}
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;
}
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 {
let mut resource = String::from(args.bucket);
if !args.object.is_empty() {
if !args.object.starts_with('/') {
resource.push('/');
}
if self.is_kms() && (resource == "/" || self.resources.is_empty()) {
break 'c self.conditions.evaluate_with_resolver(args.conditions, Some(&resolver)).await;
}
resource.push_str(args.object);
} else {
resource.push('/');
}
if self.resources.is_empty() && self.not_resources.is_empty() && !self.is_admin() && !self.is_sts() {
break 'c false;
}
if self.is_kms() && (resource == "/" || self.resources.is_empty()) {
return true;
}
if !self.resources.is_empty()
&& !self
.resources
.is_match_with_resolver(&resource, args.conditions, Some(&resolver))
.await
&& !self.is_admin()
&& !self.is_sts()
{
break 'c false;
}
if self.resources.is_empty() && self.not_resources.is_empty() && !self.is_admin() && !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()
{
if !self.resources.is_empty()
&& !self
.resources
.is_match_with_resolver(&resource, args.conditions, Some(resolver))
.await
&& !self.is_admin()
&& !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;
}
@@ -207,32 +220,41 @@ pub struct BPStatement {
}
impl BPStatement {
pub async fn is_allowed(&self, args: &BucketPolicyArgs<'_>) -> bool {
let check = 'c: {
if !self.principal.is_match(args.account) {
break 'c false;
}
/// Returns true when this statement would reach `conditions.evaluate` in [`BPStatement::is_allowed`].
pub(crate) async fn request_reaches_condition_eval(&self, args: &BucketPolicyArgs<'_>) -> bool {
if !self.principal.is_match(args.account) {
return false;
}
if (!self.actions.is_match(&args.action) && !self.actions.is_empty()) || self.not_actions.is_match(&args.action) {
break 'c false;
}
if (!self.actions.is_match(&args.action) && !self.actions.is_empty()) || self.not_actions.is_match(&args.action) {
return false;
}
let mut resource = String::from(args.bucket);
if !args.object.is_empty() {
if !args.object.starts_with('/') {
resource.push('/');
}
resource.push_str(args.object);
} else {
let mut resource = String::from(args.bucket);
if !args.object.is_empty() {
if !args.object.starts_with('/') {
resource.push('/');
}
if !self.resources.is_empty() && !self.resources.is_match(&resource, args.conditions).await {
break 'c false;
}
resource.push_str(args.object);
} 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;
}
-3
View File
@@ -132,9 +132,6 @@ pub async fn validate_admin_request_with_bucket(
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
///
/// This function provides a single entry point for authentication,
File diff suppressed because it is too large Load Diff