mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-27 15:37:02 +00:00
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:
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user