mirror of
https://github.com/rustfs/rustfs.git
synced 2026-07-26 08:18:18 +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:
@@ -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(())
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
+593
-193
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user