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