mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-05 21:07:43 +00:00
b6d4689c75
Bring the dormant Resource::Kms variant to life: arn:aws:kms:::key/<key_id> patterns (empty-account form, wildcards allowed in the id, alias/<name> reserved as parse-only) now parse, validate, serialize back, and are matched by KMS statements against the requested key id carried in Args::object. Statements without KMS resources keep the legacy match-every-key behaviour, as do call sites that pass no key resource, so nothing changes until the admin/SSE authorization paths start passing key ids. Statement validation rejects KMS resources on non-KMS statements, and bucket policy validation rejects KMS actions and resources outright while stored policies keep deserializing; evaluation skips pure-KMS bucket policy statements with a warning. A kms:Decrypt action is added for the upcoming SSE-KMS read path. Refs rustfs/backlog#1582 (part of rustfs/backlog#1562)
362 lines
15 KiB
Rust
362 lines
15 KiB
Rust
// 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.
|
|
|
|
//! Property tests for the IAM policy evaluation algebra (backlog#1151 sec-9).
|
|
//!
|
|
//! The wildcard Action/Resource matching and Deny-first evaluation in
|
|
//! `Policy::is_allowed` were guarded only by example-based tests. These
|
|
//! properties pin the three algebraic invariants that authorization safety
|
|
//! rests on, over generated inputs:
|
|
//!
|
|
//! (a) an explicit Deny matching the request denies it REGARDLESS of how many
|
|
//! Allow statements co-exist or where the Deny sits in the statement list;
|
|
//! (b) a wildcard Allow superset (`s3:*` on `bucket/*`) implies every narrower
|
|
//! concrete Allow: any request allowed by an exact-action/exact-resource
|
|
//! policy is also allowed by the widened policy;
|
|
//! (c) an empty policy denies every non-owner request (default deny).
|
|
//!
|
|
//! Properties (d)-(g) extend the same invariants to KMS key resources
|
|
//! (`arn:aws:kms:::key/<key_id>`, backlog#1582): Deny-first over key scopes,
|
|
//! wildcard/resource-less supersets implying concrete key grants, exact key
|
|
//! scoping without cross-key leaks, and the legacy resource-less
|
|
//! match-every-key compatibility pin.
|
|
//!
|
|
//! Pure evaluation: no IO, no global state, parallel-safe. Statements are built
|
|
//! from JSON exactly like production policies arriving via PutPolicy. Generated
|
|
//! bucket/key/action pools avoid wildcard metacharacters so resource patterns
|
|
//! stay exact; the wildcard forms under test are introduced deliberately by the
|
|
//! properties themselves.
|
|
|
|
use proptest::prelude::*;
|
|
use rustfs_policy::policy::{Args, Policy};
|
|
use serde_json::Value;
|
|
use std::collections::HashMap;
|
|
|
|
/// Object-level S3 actions safe to pair with an `arn:aws:s3:::<bucket>/<key>`
|
|
/// resource. Kept to real, parseable action names.
|
|
const OBJECT_ACTIONS: &[&str] = &[
|
|
"s3:GetObject",
|
|
"s3:PutObject",
|
|
"s3:DeleteObject",
|
|
"s3:GetObjectTagging",
|
|
"s3:PutObjectTagging",
|
|
];
|
|
|
|
/// Key-scoped KMS actions safe to pair with an `arn:aws:kms:::key/<id>` resource.
|
|
const KMS_KEY_ACTIONS: &[&str] = &[
|
|
"kms:GenerateDataKey",
|
|
"kms:Decrypt",
|
|
"kms:DisableKey",
|
|
"kms:RotateKey",
|
|
"kms:DescribeKey",
|
|
];
|
|
|
|
fn statement_json(effect: &str, action: &str, resource: &str) -> String {
|
|
format!(r#"{{"Effect":"{effect}","Action":["{action}"],"Resource":["{resource}"]}}"#)
|
|
}
|
|
|
|
fn resourceless_statement_json(effect: &str, action: &str) -> String {
|
|
format!(r#"{{"Effect":"{effect}","Action":["{action}"]}}"#)
|
|
}
|
|
|
|
fn policy_from_statements(statements: &[String]) -> Policy {
|
|
let json = format!(r#"{{"Version":"2012-10-17","Statement":[{}]}}"#, statements.join(","));
|
|
serde_json::from_str(&json).expect("generated policy JSON should parse")
|
|
}
|
|
|
|
fn is_allowed(policy: &Policy, action: &str, bucket: &str, object: &str) -> bool {
|
|
let conditions: HashMap<String, Vec<String>> = HashMap::new();
|
|
let claims: HashMap<String, Value> = HashMap::new();
|
|
let groups: Option<Vec<String>> = None;
|
|
pollster::block_on(policy.is_allowed(&Args {
|
|
account: "PROPTESTUSER",
|
|
groups: &groups,
|
|
action: action.try_into().expect("action from the fixed pool should parse"),
|
|
bucket,
|
|
conditions: &conditions,
|
|
is_owner: false,
|
|
object,
|
|
claims: &claims,
|
|
deny_only: false,
|
|
}))
|
|
}
|
|
|
|
/// Strategy: a bucket name without wildcard metacharacters.
|
|
fn bucket_strategy() -> impl Strategy<Value = String> {
|
|
"[a-z][a-z0-9]{2,7}"
|
|
}
|
|
|
|
/// Strategy: an object key (optionally nested one level) without metacharacters.
|
|
fn key_strategy() -> impl Strategy<Value = String> {
|
|
"[a-z0-9]{1,12}(/[a-z0-9]{1,12})?"
|
|
}
|
|
|
|
/// Strategy: one action name from the object-action pool.
|
|
fn action_strategy() -> impl Strategy<Value = &'static str> {
|
|
proptest::sample::select(OBJECT_ACTIONS)
|
|
}
|
|
|
|
/// Strategy: a KMS key id without wildcard metacharacters or separators.
|
|
fn key_id_strategy() -> impl Strategy<Value = String> {
|
|
"[a-z][a-z0-9-]{2,11}"
|
|
}
|
|
|
|
/// Strategy: one action name from the key-scoped KMS action pool.
|
|
fn kms_action_strategy() -> impl Strategy<Value = &'static str> {
|
|
proptest::sample::select(KMS_KEY_ACTIONS)
|
|
}
|
|
|
|
/// KMS evaluation contract: the requested key id travels in `args.object` with an
|
|
/// empty bucket (see `Statement::kms_key_scope_matches`).
|
|
fn is_allowed_for_key(policy: &Policy, action: &str, key_id: &str) -> bool {
|
|
is_allowed(policy, action, "", key_id)
|
|
}
|
|
|
|
proptest! {
|
|
/// (a) Deny anywhere wins: a Deny statement matching the request denies it,
|
|
/// no matter how many broad Allow statements surround it or at which index
|
|
/// it appears. A regression here (e.g. Allow short-circuiting before the
|
|
/// Deny scan, or statement order leaking into the decision) is privilege
|
|
/// escalation.
|
|
#[test]
|
|
fn explicit_deny_anywhere_denies(
|
|
bucket in bucket_strategy(),
|
|
key in key_strategy(),
|
|
action in action_strategy(),
|
|
allow_count in 0usize..4,
|
|
deny_pos_seed in 0usize..16,
|
|
broad in proptest::bool::ANY,
|
|
) {
|
|
// Broad Allow statements that all match the request on their own.
|
|
let allow_resource = if broad {
|
|
"arn:aws:s3:::*".to_string()
|
|
} else {
|
|
format!("arn:aws:s3:::{bucket}/*")
|
|
};
|
|
let mut statements: Vec<String> = (0..allow_count)
|
|
.map(|_| statement_json("Allow", "s3:*", &allow_resource))
|
|
.collect();
|
|
|
|
// One Deny that matches the exact request.
|
|
let deny = statement_json("Deny", action, &format!("arn:aws:s3:::{bucket}/{key}"));
|
|
let deny_pos = deny_pos_seed % (statements.len() + 1);
|
|
statements.insert(deny_pos, deny);
|
|
|
|
let policy = policy_from_statements(&statements);
|
|
|
|
// Sanity: without the Deny, the Allows (when present) do allow.
|
|
if allow_count > 0 {
|
|
let mut allows_only = statements.clone();
|
|
allows_only.remove(deny_pos);
|
|
let allow_policy = policy_from_statements(&allows_only);
|
|
prop_assert!(
|
|
is_allowed(&allow_policy, action, &bucket, &key),
|
|
"sanity: the Allow statements alone should permit {action} on {bucket}/{key}"
|
|
);
|
|
}
|
|
|
|
prop_assert!(
|
|
!is_allowed(&policy, action, &bucket, &key),
|
|
"explicit Deny at index {deny_pos} of {} statements must deny {action} on {bucket}/{key}",
|
|
statements.len()
|
|
);
|
|
}
|
|
|
|
/// (b) Wildcard superset implies the narrower concrete match: if an
|
|
/// exact-action / exact-resource Allow permits a request, then widening
|
|
/// that policy to `s3:*` on `arn:aws:s3:::<bucket>/*` (and further to
|
|
/// `*` on `arn:aws:s3:::*`) must also permit it. A regression here means
|
|
/// wildcard grants silently stop covering what their concrete forms cover.
|
|
#[test]
|
|
fn wildcard_superset_implies_concrete_match(
|
|
bucket in bucket_strategy(),
|
|
key in key_strategy(),
|
|
action in action_strategy(),
|
|
probe_bucket in bucket_strategy(),
|
|
probe_key in key_strategy(),
|
|
probe_action in action_strategy(),
|
|
) {
|
|
let narrow = policy_from_statements(&[statement_json(
|
|
"Allow",
|
|
action,
|
|
&format!("arn:aws:s3:::{bucket}/{key}"),
|
|
)]);
|
|
let widened = policy_from_statements(&[statement_json(
|
|
"Allow",
|
|
"s3:*",
|
|
&format!("arn:aws:s3:::{bucket}/*"),
|
|
)]);
|
|
let widest = policy_from_statements(&[statement_json("Allow", "*", "arn:aws:s3:::*")]);
|
|
|
|
// The request the narrow policy was built for is allowed at every width.
|
|
prop_assert!(is_allowed(&narrow, action, &bucket, &key), "narrow policy must allow its own grant");
|
|
prop_assert!(is_allowed(&widened, action, &bucket, &key), "s3:* on bucket/* must imply the concrete grant");
|
|
prop_assert!(is_allowed(&widest, action, &bucket, &key), "* on arn:aws:s3:::* must imply the concrete grant");
|
|
|
|
// Implication over arbitrary probes: anything the narrow policy allows,
|
|
// the supersets allow too (contrapositive-checked on random requests
|
|
// that may or may not match the narrow grant).
|
|
if is_allowed(&narrow, probe_action, &probe_bucket, &probe_key) {
|
|
prop_assert!(
|
|
is_allowed(&widened, probe_action, &probe_bucket, &probe_key),
|
|
"superset (s3:* on {bucket}/*) must allow whatever the narrow policy allows: {probe_action} on {probe_bucket}/{probe_key}"
|
|
);
|
|
prop_assert!(
|
|
is_allowed(&widest, probe_action, &probe_bucket, &probe_key),
|
|
"superset (* on arn:aws:s3:::*) must allow whatever the narrow policy allows"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// (c) Default deny: a policy with no statements denies every non-owner
|
|
/// request, whatever the action/bucket/object.
|
|
#[test]
|
|
fn empty_policy_denies_everything(
|
|
bucket in bucket_strategy(),
|
|
key in key_strategy(),
|
|
action in action_strategy(),
|
|
) {
|
|
let empty: Policy = serde_json::from_str(r#"{"Version":"2012-10-17","Statement":[]}"#)
|
|
.expect("empty policy JSON should parse");
|
|
prop_assert!(
|
|
!is_allowed(&empty, action, &bucket, &key),
|
|
"empty policy must deny {action} on {bucket}/{key}"
|
|
);
|
|
|
|
let default_policy = Policy::default();
|
|
prop_assert!(
|
|
!is_allowed(&default_policy, action, &bucket, &key),
|
|
"Policy::default() must deny {action} on {bucket}/{key}"
|
|
);
|
|
}
|
|
|
|
/// (d) KMS Deny anywhere wins: a Deny scoped to the exact key (or `key/*`)
|
|
/// denies the request no matter how many broad KMS Allow statements
|
|
/// (resource-less or `key/*`-scoped) surround it.
|
|
#[test]
|
|
fn kms_explicit_deny_anywhere_denies(
|
|
key_id in key_id_strategy(),
|
|
action in kms_action_strategy(),
|
|
allow_count in 0usize..4,
|
|
deny_pos_seed in 0usize..16,
|
|
broad in proptest::bool::ANY,
|
|
wildcard_deny in proptest::bool::ANY,
|
|
) {
|
|
let mut statements: Vec<String> = (0..allow_count)
|
|
.map(|_| {
|
|
if broad {
|
|
resourceless_statement_json("Allow", "kms:*")
|
|
} else {
|
|
statement_json("Allow", "kms:*", "arn:aws:kms:::key/*")
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
let deny_resource = if wildcard_deny {
|
|
"arn:aws:kms:::key/*".to_string()
|
|
} else {
|
|
format!("arn:aws:kms:::key/{key_id}")
|
|
};
|
|
let deny = statement_json("Deny", action, &deny_resource);
|
|
let deny_pos = deny_pos_seed % (statements.len() + 1);
|
|
statements.insert(deny_pos, deny);
|
|
|
|
let policy = policy_from_statements(&statements);
|
|
|
|
if allow_count > 0 {
|
|
let mut allows_only = statements.clone();
|
|
allows_only.remove(deny_pos);
|
|
let allow_policy = policy_from_statements(&allows_only);
|
|
prop_assert!(
|
|
is_allowed_for_key(&allow_policy, action, &key_id),
|
|
"sanity: the KMS Allow statements alone should permit {action} on key {key_id}"
|
|
);
|
|
}
|
|
|
|
prop_assert!(
|
|
!is_allowed_for_key(&policy, action, &key_id),
|
|
"explicit KMS Deny at index {deny_pos} of {} statements must deny {action} on key {key_id}",
|
|
statements.len()
|
|
);
|
|
}
|
|
|
|
/// (e) KMS wildcard superset implies the concrete key grant: whatever an
|
|
/// exact `key/<id>` Allow permits is also permitted by `key/*`, by a bare
|
|
/// `arn:aws:kms:::*`, and by the legacy resource-less statement form.
|
|
#[test]
|
|
fn kms_wildcard_superset_implies_concrete_match(
|
|
key_id in key_id_strategy(),
|
|
action in kms_action_strategy(),
|
|
) {
|
|
let narrow = policy_from_statements(&[statement_json(
|
|
"Allow",
|
|
action,
|
|
&format!("arn:aws:kms:::key/{key_id}"),
|
|
)]);
|
|
let widened = policy_from_statements(&[statement_json("Allow", "kms:*", "arn:aws:kms:::key/*")]);
|
|
let star = policy_from_statements(&[statement_json("Allow", "kms:*", "arn:aws:kms:::*")]);
|
|
let resourceless = policy_from_statements(&[resourceless_statement_json("Allow", "kms:*")]);
|
|
|
|
prop_assert!(is_allowed_for_key(&narrow, action, &key_id), "narrow KMS policy must allow its own grant");
|
|
prop_assert!(is_allowed_for_key(&widened, action, &key_id), "kms:* on key/* must imply the concrete grant");
|
|
prop_assert!(is_allowed_for_key(&star, action, &key_id), "kms:* on arn:aws:kms:::* must imply the concrete grant");
|
|
prop_assert!(
|
|
is_allowed_for_key(&resourceless, action, &key_id),
|
|
"the legacy resource-less KMS statement must imply the concrete grant"
|
|
);
|
|
}
|
|
|
|
/// (f) Key scoping is exact: an Allow on `key/<a>` never leaks to a
|
|
/// different key id, while the compatibility contract keeps unscoped
|
|
/// requests (no key id passed) matching.
|
|
#[test]
|
|
fn kms_key_scope_does_not_leak_across_keys(
|
|
key_a in key_id_strategy(),
|
|
key_b in key_id_strategy(),
|
|
action in kms_action_strategy(),
|
|
) {
|
|
prop_assume!(key_a != key_b);
|
|
|
|
let policy = policy_from_statements(&[statement_json(
|
|
"Allow",
|
|
action,
|
|
&format!("arn:aws:kms:::key/{key_a}"),
|
|
)]);
|
|
|
|
prop_assert!(is_allowed_for_key(&policy, action, &key_a), "the granted key must be allowed");
|
|
prop_assert!(
|
|
!is_allowed_for_key(&policy, action, &key_b),
|
|
"an Allow scoped to key {key_a} must not leak to key {key_b}"
|
|
);
|
|
prop_assert!(
|
|
is_allowed_for_key(&policy, action, ""),
|
|
"call sites that pass no key resource keep the legacy match-every-key behaviour"
|
|
);
|
|
}
|
|
|
|
/// (g) Legacy compatibility pin: resource-less KMS statements match every
|
|
/// generated key id, exactly as before KMS resources existed.
|
|
#[test]
|
|
fn kms_resourceless_statement_matches_every_key(
|
|
key_id in key_id_strategy(),
|
|
action in kms_action_strategy(),
|
|
) {
|
|
let policy = policy_from_statements(&[resourceless_statement_json("Allow", action)]);
|
|
prop_assert!(
|
|
is_allowed_for_key(&policy, action, &key_id),
|
|
"resource-less KMS statement must keep matching {action} on key {key_id}"
|
|
);
|
|
}
|
|
}
|