diff --git a/crates/policy/tests/bucket_iam_authz_matrix.rs b/crates/policy/tests/bucket_iam_authz_matrix.rs new file mode 100644 index 000000000..e8887ac71 --- /dev/null +++ b/crates/policy/tests/bucket_iam_authz_matrix.rs @@ -0,0 +1,311 @@ +// 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. + +//! Bucket-policy x IAM-policy priority conflict matrix (backlog#1151 sec-8). +//! +//! Individual policy layers are well tested in isolation, but the CROSS-LAYER +//! resolution — which layer wins when IAM and bucket policy disagree — had no +//! coverage. A priority error here is a data-exposure or data-lockout bug, so +//! this table-driven test pins the combination for all four Allow/Deny quadrants +//! plus the anonymous (no-credential) case. +//! +//! Pure evaluation only: it drives `Policy::is_allowed` (IAM, +//! `crates/policy/src/policy/policy.rs:69`) and `BucketPolicy::is_allowed` +//! (bucket, `:207`) directly through a helper that models the request-layer +//! orchestration in `rustfs::storage::access::authorize_request`. No global +//! singletons, no IAM store, no server. +//! +//! ## The orchestration being characterized (authorize_request) +//! +//! For an authenticated, non-owner request on a normal (non-policy-API) action: +//! 1. Bucket explicit-Deny gate — the bucket policy is evaluated as `is_owner` +//! so ONLY its Deny statements can reject; a matching bucket Deny denies +//! regardless of what IAM says. This gate runs BEFORE the IAM-allow +//! short-circuit, which is why a bucket Deny beats an IAM Allow. +//! 2. IAM allow — if the identity policy allows, the request is permitted. +//! 3. Bucket-policy allow fallback — if IAM did not allow, a bucket Allow can +//! still permit the request. +//! +//! Anonymous requests skip 1-3 and are governed by the bucket policy alone. +//! +//! ## Invariant surfaced by the matrix (read before changing an assertion) +//! +//! A **bucket** explicit Deny is a hard gate and always wins (cases +//! `iam_allow_x_bucket_deny`, `iam_deny_x_bucket_deny`, `anon_x_bucket_deny`). +//! An **IAM** explicit Deny is NOT a hard gate: because step 3 falls back to the +//! bucket policy, a bucket Allow overrides an IAM explicit Deny +//! (`iam_deny_x_bucket_allow` => allowed). This is RustFS's (MinIO-lineage) +//! resolution and it DIVERGES from AWS's "an explicit Deny in any policy always +//! wins". The case below pins the actual behavior; if RustFS later hardens IAM +//! Deny into a hard gate, that case flips to `false` and must be updated +//! deliberately. + +use rustfs_policy::policy::action::{Action, S3Action}; +use rustfs_policy::policy::{Args, BucketPolicy, BucketPolicyArgs, Policy}; +use serde_json::Value; +use std::collections::HashMap; + +const BUCKET: &str = "mybucket"; +const OBJECT: &str = "obj"; +const USER: &str = "TESTUSER"; + +fn get_object() -> Action { + Action::S3Action(S3Action::GetObjectAction) +} + +fn iam(effect: &str) -> Policy { + let json = format!( + r#"{{"Version":"2012-10-17","Statement":[{{"Effect":"{effect}","Action":["s3:GetObject"],"Resource":["arn:aws:s3:::mybucket/*"]}}]}}"# + ); + serde_json::from_str(&json).expect("IAM policy JSON should parse") +} + +fn bucket(effect: &str) -> BucketPolicy { + let json = format!( + r#"{{"Version":"2012-10-17","Statement":[{{"Effect":"{effect}","Principal":{{"AWS":["*"]}},"Action":["s3:GetObject"],"Resource":["arn:aws:s3:::mybucket/*"]}}]}}"# + ); + serde_json::from_str(&json).expect("bucket policy JSON should parse") +} + +/// Models `rustfs::storage::access::authorize_request` for a normal +/// (non-policy-API) action, using the real policy evaluators. `account.is_empty()` +/// marks an anonymous request. Kept in lockstep with that function's layering; +/// if the orchestration there changes, update this helper and the table together. +async fn authorize(iam_policy: Option<&Policy>, bucket_policy: Option<&BucketPolicy>, account: &str) -> bool { + let conditions: HashMap> = HashMap::new(); + let claims: HashMap = HashMap::new(); + let groups: Option> = None; + let action = get_object(); + + let bucket_args = |is_owner: bool| BucketPolicyArgs { + bucket: BUCKET, + action, + is_owner, + account, + groups: &groups, + conditions: &conditions, + object: OBJECT, + }; + + // Anonymous: bucket policy alone (is_owner = false); a bucket Deny rejects + // via its own Deny loop, a bucket Allow (Principal *) permits. + if account.is_empty() { + return match bucket_policy { + Some(bp) => bp.is_allowed(&bucket_args(false)).await, + None => false, + }; + } + + // (1) Bucket explicit-Deny gate: is_owner isolates Deny statements. + if let Some(bp) = bucket_policy + && !bp.is_allowed(&bucket_args(true)).await + { + return false; + } + + // (2) IAM allow. + if let Some(p) = iam_policy { + let allowed = p + .is_allowed(&Args { + account, + groups: &groups, + action, + bucket: BUCKET, + conditions: &conditions, + is_owner: false, + object: OBJECT, + claims: &claims, + deny_only: false, + }) + .await; + if allowed { + return true; + } + } + + // (3) Bucket-policy allow fallback. + if let Some(bp) = bucket_policy + && bp.is_allowed(&bucket_args(false)).await + { + return true; + } + + false +} + +struct Case { + name: &'static str, + iam: Option, + bucket: Option, + account: &'static str, + expected: bool, +} + +#[test] +fn bucket_iam_priority_matrix() { + let cases = vec![ + // --- Four Allow/Deny quadrants (authenticated, non-owner) --- + Case { + name: "iam_allow_x_bucket_allow", + iam: Some(iam("Allow")), + bucket: Some(bucket("Allow")), + account: USER, + expected: true, + }, + Case { + // Bucket explicit Deny wins over IAM Allow (deny gate before IAM short-circuit). + name: "iam_allow_x_bucket_deny", + iam: Some(iam("Allow")), + bucket: Some(bucket("Deny")), + account: USER, + expected: false, + }, + Case { + // IAM explicit Deny is SOFT: the bucket Allow fallback overrides it. + // Diverges from AWS "explicit Deny always wins"; pins current behavior. + name: "iam_deny_x_bucket_allow", + iam: Some(iam("Deny")), + bucket: Some(bucket("Allow")), + account: USER, + expected: true, + }, + Case { + name: "iam_deny_x_bucket_deny", + iam: Some(iam("Deny")), + bucket: Some(bucket("Deny")), + account: USER, + expected: false, + }, + // --- No bucket policy: IAM alone governs --- + Case { + name: "iam_allow_x_no_bucket", + iam: Some(iam("Allow")), + bucket: None, + account: USER, + expected: true, + }, + Case { + name: "iam_deny_x_no_bucket", + iam: Some(iam("Deny")), + bucket: None, + account: USER, + expected: false, + }, + // --- No IAM grant: bucket Allow fallback can still permit --- + Case { + name: "iam_empty_x_bucket_allow", + iam: Some(iam("Deny")), // Deny here == "IAM does not grant"; fallback decides + bucket: Some(bucket("Allow")), + account: USER, + expected: true, + }, + Case { + name: "no_grant_anywhere_denies", + iam: None, + bucket: None, + account: USER, + expected: false, + }, + // --- Anonymous (no credentials): bucket policy alone --- + Case { + name: "anon_x_bucket_allow", + iam: None, + bucket: Some(bucket("Allow")), + account: "", + expected: true, + }, + Case { + name: "anon_x_bucket_deny", + iam: None, + bucket: Some(bucket("Deny")), + account: "", + expected: false, + }, + Case { + name: "anon_x_no_bucket", + iam: None, + bucket: None, + account: "", + expected: false, + }, + ]; + + for case in &cases { + let got = pollster::block_on(authorize(case.iam.as_ref(), case.bucket.as_ref(), case.account)); + assert_eq!( + got, case.expected, + "quadrant `{}`: expected allowed={}, got {}", + case.name, case.expected, got + ); + } +} + +/// Within a single IAM policy, an explicit Deny beats a co-resident Allow for the +/// same action (Deny-first evaluation, `policy.rs:71`). This is the intra-policy +/// half of "explicit Deny wins" and is independent of the cross-layer gate above. +#[test] +fn intra_iam_policy_explicit_deny_beats_allow() { + let policy: Policy = serde_json::from_str( + r#"{"Version":"2012-10-17","Statement":[ + {"Effect":"Allow","Action":["s3:GetObject"],"Resource":["arn:aws:s3:::mybucket/*"]}, + {"Effect":"Deny","Action":["s3:GetObject"],"Resource":["arn:aws:s3:::mybucket/*"]} + ]}"#, + ) + .expect("IAM policy JSON should parse"); + + let conditions: HashMap> = HashMap::new(); + let claims: HashMap = HashMap::new(); + let groups: Option> = None; + let allowed = pollster::block_on(policy.is_allowed(&Args { + account: USER, + groups: &groups, + action: get_object(), + bucket: BUCKET, + conditions: &conditions, + is_owner: false, + object: OBJECT, + claims: &claims, + deny_only: false, + })); + assert!(!allowed, "an explicit IAM Deny must beat a co-resident Allow for the same action"); +} + +/// Within a single bucket policy, an explicit Deny beats a co-resident Allow, and +/// the deny holds even for an owner-context evaluation (`policy.rs:208`) — this is +/// exactly the property the authenticated deny gate relies on. +#[test] +fn intra_bucket_policy_explicit_deny_beats_allow_even_for_owner() { + let policy: BucketPolicy = serde_json::from_str( + r#"{"Version":"2012-10-17","Statement":[ + {"Effect":"Allow","Principal":{"AWS":["*"]},"Action":["s3:GetObject"],"Resource":["arn:aws:s3:::mybucket/*"]}, + {"Effect":"Deny","Principal":{"AWS":["*"]},"Action":["s3:GetObject"],"Resource":["arn:aws:s3:::mybucket/*"]} + ]}"#, + ) + .expect("bucket policy JSON should parse"); + + let conditions: HashMap> = HashMap::new(); + let groups: Option> = None; + for is_owner in [false, true] { + let allowed = pollster::block_on(policy.is_allowed(&BucketPolicyArgs { + bucket: BUCKET, + action: get_object(), + is_owner, + account: USER, + groups: &groups, + conditions: &conditions, + object: OBJECT, + })); + assert!(!allowed, "an explicit bucket Deny must beat a co-resident Allow (is_owner={is_owner})"); + } +}