Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 69 additions & 42 deletions crates/fakecloud-core/src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,20 @@ pub struct ConditionContext {
pub principal_tags: Option<HashMap<String, String>>,
}

/// Whether two condition key names are the same key: the `service:name`
/// part compares case-insensitively, and anything after the first `/` (a tag
/// key in `aws:RequestTag/<key>`) compares exactly.
fn same_condition_key(a: &str, b: &str) -> bool {
fn split(k: &str) -> (&str, &str) {
match k.find('/') {
Some(i) => (&k[..i], &k[i..]),
None => (k, ""),
}
}
let ((a_name, a_tail), (b_name, b_tail)) = (split(a), split(b));
a_name.eq_ignore_ascii_case(b_name) && a_tail == b_tail
}

impl ConditionContext {
/// Resolve a condition key (e.g. `"aws:username"`) to the list of
/// context values. Returns `None` if the key is not populated.
Expand All @@ -308,38 +322,51 @@ impl ConditionContext {
//
// Prefix lengths: "aws:resourcetag/" = 16, "aws:requesttag/" = 15,
// "aws:principaltag/" = 17
if lower.starts_with("aws:resourcetag/") {
let tagged = if lower.starts_with("aws:resourcetag/") {
let tag_key = &key[16..]; // preserve original case
return self
.resource_tags
.as_ref()
.and_then(|tags| tags.get(tag_key))
.map(|v| vec![v.clone()]);
}
if lower.starts_with("aws:requesttag/") {
Some(
self.resource_tags
.as_ref()
.and_then(|tags| tags.get(tag_key))
.map(|v| vec![v.clone()]),
)
} else if lower.starts_with("aws:requesttag/") {
let tag_key = &key[15..];
return self
.request_tags
.as_ref()
.and_then(|tags| tags.get(tag_key))
.map(|v| vec![v.clone()]);
}
if lower.starts_with("aws:principaltag/") {
Some(
self.request_tags
.as_ref()
.and_then(|tags| tags.get(tag_key))
.map(|v| vec![v.clone()]),
)
} else if lower.starts_with("aws:principaltag/") {
let tag_key = &key[17..];
return self
.principal_tags
.as_ref()
.and_then(|tags| tags.get(tag_key))
.map(|v| vec![v.clone()]);
}
if lower == "aws:tagkeys" {
return self
.request_tags
.as_ref()
.map(|tags| tags.keys().cloned().collect());
Some(
self.principal_tags
.as_ref()
.and_then(|tags| tags.get(tag_key))
.map(|v| vec![v.clone()]),
)
} else if lower == "aws:tagkeys" {
Some(
self.request_tags
.as_ref()
.map(|tags| tags.keys().cloned().collect()),
)
} else {
None
};
if let Some(tagged) = tagged {
// Tag keys are case-sensitive after the prefix, so a plain entry
// must match the key exactly.
return tagged.or_else(|| {
self.service_keys
.iter()
.find(|(entry, _)| same_condition_key(entry, key))
.map(|(_, vs)| vs.clone())
});
}

match lower.as_str() {
let typed = match lower.as_str() {
"aws:username" => self.aws_username.as_deref().and_then(one),
"aws:userid" => self.aws_userid.as_deref().and_then(one),
"aws:principalarn" => self.aws_principal_arn.as_deref().and_then(one),
Expand Down Expand Up @@ -368,21 +395,21 @@ impl ConditionContext {
"aws:tokenissuetime" => self
.aws_token_issue_time
.map(|t| vec![t.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)]),
_ => {
if let Some(vs) = self.service_keys.get(&lower) {
if vs.is_empty() {
None
} else {
Some(vs.clone())
}
} else {
self.service_keys
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case(key))
.map(|(_, vs)| vs.clone())
}
}
}
_ => None,
};
// A key with no typed value -- a service-specific key, or a global key
// supplied as a plain entry (a policy simulator's ContextEntries) --
// comes from `service_keys`. An entry with an empty value list means
// the key applies to the request but carries no values, which set
// operators distinguish from a key that was never populated.
typed.or_else(|| {
self.service_keys.get(&lower).cloned().or_else(|| {
self.service_keys
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case(key))
.map(|(_, vs)| vs.clone())
})
})
}
}

Expand Down
73 changes: 73 additions & 0 deletions crates/fakecloud-e2e/tests/iam_enforcement_abac.rs
Original file line number Diff line number Diff line change
Expand Up @@ -571,3 +571,76 @@ async fn iam_resource_tag_denies_get_user_without_matching_tag() {
"expected AccessDenied for dev-tagged user"
);
}

// ======================================================================
// Policy variables
// ======================================================================

fn s3_for(cfg: &aws_config::SdkConfig) -> S3Client {
S3Client::from_conf(
aws_sdk_s3::config::Builder::from(cfg)
.force_path_style(true)
.build(),
)
}

/// `${aws:username}` in a Resource and `${aws:PrincipalTag/team}` in a
/// condition are replaced per caller, so one policy scopes each user to their
/// own prefix and team.
#[tokio::test]
async fn policy_variables_scope_one_policy_per_caller() {
let server = start_strict().await;
let admin = s3_for(&sdk_config_with(&server, "test", "test").await);
admin.create_bucket().bucket("homes").send().await.unwrap();
for key in [
"alice/doc.txt",
"bob/doc.txt",
"blue/plan.txt",
"red/plan.txt",
] {
admin
.put_object()
.bucket("homes")
.key(key)
.body(aws_sdk_s3::primitives::ByteStream::from_static(b"x"))
.send()
.await
.unwrap();
}

let policy = serde_json::json!({
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::homes/${aws:username}/*"
},
{
"Effect": "Allow",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::homes/*",
"Condition": {"StringLike": {"s3:ExistingObjectTag/none": "x"}}
},
{
"Effect": "Allow",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::homes/${aws:PrincipalTag/team, 'no-team'}/*"
}
]
})
.to_string();
let (akid, secret) = bootstrap_tagged_user(&server, "alice", &[("team", "blue")]).await;
attach_inline_policy(&server, "alice", "homes", &policy).await;
let alice = s3_for(&sdk_config_with(&server, &akid, &secret).await);

let get = |key: &'static str| alice.get_object().bucket("homes").key(key).send();
get("alice/doc.txt").await.expect("own username prefix");
get("blue/plan.txt").await.expect("own team prefix");
let err = get("bob/doc.txt").await.expect_err("another user's prefix");
assert!(format!("{err:?}").contains("AccessDenied"), "{err:?}");
let err = get("red/plan.txt")
.await
.expect_err("another team's prefix");
assert!(format!("{err:?}").contains("AccessDenied"), "{err:?}");
}
37 changes: 37 additions & 0 deletions crates/fakecloud-e2e/tests/iam_simulate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -311,3 +311,40 @@ async fn simulate_principal_policy_via_attached_aws_managed_policy() {
"implicitDeny"
);
}

/// A policy variable resolves from a simulator context entry, so
/// `${aws:username}` policies can be tested with SimulateCustomPolicy.
#[tokio::test]
async fn simulate_custom_policy_resolves_policy_variables_from_context_entries() {
let server = TestServer::start().await;
let iam = server.iam_client().await;
let policy = r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:aws:s3:::home/${aws:username}/*"}]}"#;
let decision = |resource: &'static str| {
let iam = iam.clone();
async move {
iam.simulate_custom_policy()
.policy_input_list(policy)
.action_names("s3:GetObject")
.resource_arns(resource)
.context_entries(
ContextEntry::builder()
.context_key_name("aws:username")
.context_key_values("alice")
.context_key_type(ContextKeyTypeEnum::String)
.build(),
)
.send()
.await
.unwrap()
.evaluation_results()[0]
.eval_decision()
.as_str()
.to_string()
}
};
assert_eq!(decision("arn:aws:s3:::home/alice/notes").await, "allowed");
assert_eq!(
decision("arn:aws:s3:::home/bob/notes").await,
"implicitDeny"
);
}
86 changes: 86 additions & 0 deletions crates/fakecloud-iam/src/condition.rs
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,9 @@ pub struct ParsedCondition {
pub operator: ParsedOperatorName,
pub key: String,
pub values: Vec<String>,
/// Whether the policy's language version expands `${...}` policy
/// variables in `values` (`2012-10-17`).
pub policy_variables: bool,
}

/// A statement's fully-parsed `Condition` block. Multiple entries are
Expand Down Expand Up @@ -242,6 +245,7 @@ impl CompiledCondition {
},
key: format!("__unknown_operator__:{op_name}"),
values: Vec::new(),
policy_variables: false,
});
continue;
};
Expand All @@ -254,12 +258,22 @@ impl CompiledCondition {
operator,
key: key.clone(),
values,
policy_variables: false,
});
}
}
out
}

/// Mark whether the block's policy expands `${...}` policy variables
/// (only `Version: 2012-10-17` policies do).
pub fn with_policy_variables(mut self, enabled: bool) -> Self {
for entry in &mut self.entries {
entry.policy_variables = enabled;
}
self
}

/// Evaluate this condition block against a [`ConditionContext`].
/// Returns `true` iff every entry matches (AND semantics).
pub fn matches(&self, ctx: &ConditionContext) -> bool {
Expand Down Expand Up @@ -313,6 +327,12 @@ pub fn evaluate_entry(entry: &ParsedCondition, ctx: &ConditionContext) -> bool {
// Missing key handling.
let context_values = match context_values {
Some(vs) if !vs.is_empty() => vs,
// The service populated the key and the request carries no values for
// it. `ForAllValues` is vacuously true then, as AWS documents ("every
// value matches" holds for none). A key that was never populated is
// not treated the same way: it may be one fakecloud does not extract,
// and granting on it would fail open.
Some(_) if entry.operator.qualifier == Qualifier::ForAllValues => return true,
_ => {
// Key not populated. `IfExists` -> vacuously true. Otherwise
// this is a safe-fail to false.
Expand All @@ -331,6 +351,25 @@ pub fn evaluate_entry(entry: &ParsedCondition, ctx: &ConditionContext) -> bool {
}
};

if entry.policy_variables
&& supports_policy_variables(entry.operator.op)
&& entry
.values
.iter()
.any(|v| crate::policy_variables::has_variables(v))
{
let expanded: Vec<Option<Vec<crate::policy_variables::Piece>>> = entry
.values
.iter()
.map(|v| crate::policy_variables::expand(v, ctx))
.collect();
let one = |cv: &String| match_expanded(entry.operator.op, &expanded, cv);
return match entry.operator.qualifier {
Qualifier::Single | Qualifier::ForAnyValue => context_values.iter().any(one),
Qualifier::ForAllValues => context_values.iter().all(one),
};
}

match entry.operator.qualifier {
Qualifier::Single | Qualifier::ForAnyValue => {
// ANY context value satisfies the operator against the
Expand All @@ -349,6 +388,53 @@ pub fn evaluate_entry(entry: &ParsedCondition, ctx: &ConditionContext) -> bool {
}
}

/// Policy variables are expanded only in string and ARN comparisons.
fn supports_policy_variables(op: ConditionOperator) -> bool {
use ConditionOperator::*;
matches!(
op,
StringEquals
| StringNotEquals
| StringEqualsIgnoreCase
| StringNotEqualsIgnoreCase
| StringLike
| StringNotLike
| ArnEquals
| ArnLike
| ArnNotEquals
| ArnNotLike
)
}

/// [`match_values`] for policy values with variables expanded. A value whose
/// variable has no value (`None`) is null: no positive operator matches it,
/// and every inverted one does.
fn match_expanded(
op: ConditionOperator,
values: &[Option<Vec<crate::policy_variables::Piece>>],
context_value: &str,
) -> bool {
use crate::policy_variables::{glob as piece_glob, to_text};
use ConditionOperator::*;
let positive = |pred: &dyn Fn(&[crate::policy_variables::Piece]) -> bool| {
values.iter().any(|v| v.as_deref().is_some_and(pred))
};
let inverted = |pred: &dyn Fn(&[crate::policy_variables::Piece]) -> bool| {
values.iter().all(|v| v.as_deref().is_none_or(|p| !pred(p)))
};
match op {
StringEquals => positive(&|p| to_text(p) == context_value),
StringNotEquals => inverted(&|p| to_text(p) == context_value),
StringEqualsIgnoreCase => positive(&|p| to_text(p).eq_ignore_ascii_case(context_value)),
StringNotEqualsIgnoreCase => inverted(&|p| to_text(p).eq_ignore_ascii_case(context_value)),
StringLike | ArnEquals | ArnLike => positive(&|p| piece_glob(p, context_value, false)),
StringNotLike | ArnNotEquals | ArnNotLike => {
inverted(&|p| piece_glob(p, context_value, false))
}
_ => false,
}
}

/// `Null` operator: `{ "Null": { "aws:username": "true" } }` -> passes
/// iff the key is missing. `"false"` -> passes iff the key is present.
fn evaluate_null(entry: &ParsedCondition, ctx: &ConditionContext) -> bool {
Expand Down
Loading
Loading