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
79 changes: 79 additions & 0 deletions crates/fakecloud-cloudformation/src/resource_provisioner/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8633,6 +8633,85 @@ mod tests {
);
}

#[test]
fn lambda_function_role_trust_is_read_in_both_accounts() {
// Two accounts each hold a role named `app`; only one trusts Lambda.
let mut prov = make_provisioner();
let role = |trusts_lambda: bool| {
let statement = if trusts_lambda {
serde_json::json!([{
"Effect": "Allow",
"Principal": {"Service": "lambda.amazonaws.com"},
"Action": "sts:AssumeRole"
}])
} else {
serde_json::json!([])
};
make_resource(
"AWS::IAM::Role",
"App",
serde_json::json!({
"RoleName": "app",
"AssumeRolePolicyDocument": {"Version": "2012-10-17", "Statement": statement}
}),
)
};
prov.account_id = "999999999999".to_string();
let foreign = prov.create_resource(&role(true)).unwrap().physical_id;
prov.account_id = "123456789012".to_string();
let own = prov.create_resource(&role(false)).unwrap().physical_id;
assert_eq!(foreign, "arn:aws:iam::999999999999:role/app");
assert_eq!(own, "arn:aws:iam::123456789012:role/app");

let func = |name: &str, role: &str| {
make_resource(
"AWS::Lambda::Function",
"Fn",
serde_json::json!({
"FunctionName": name,
"Runtime": "python3.12",
"Handler": "index.handler",
"Role": role,
"Code": {"ZipFile": "def handler(e,c): return e"}
}),
)
};
// The foreign role trusts Lambda, but the session is minted for its
// untrusting namesake in the stack's account, which refuses it.
let err = prov
.create_resource(&func("foreign-fn", &foreign))
.unwrap_err();
assert!(
err.contains("InvalidParameterValueException") && err.contains(&own),
"{err}"
);
let err = prov.create_resource(&func("own-fn", &own)).unwrap_err();
assert!(err.contains("InvalidParameterValueException"), "{err}");

// Swap the trust: now the stack's role trusts Lambda, the foreign
// one does not, and the foreign one is refused on its own policy.
prov.account_id = "999999999999".to_string();
prov.create_resource(&role(false)).unwrap();
prov.account_id = "123456789012".to_string();
prov.create_resource(&role(true)).unwrap();
let err = prov
.create_resource(&func("foreign-fn-2", &foreign))
.unwrap_err();
assert!(
err.contains("InvalidParameterValueException") && err.contains(&foreign),
"{err}"
);
prov.create_resource(&func("own-fn-2", &own))
.expect("stack's role trusting Lambda accepted");

// Both trust Lambda: the foreign role is accepted (IAM off).
prov.account_id = "999999999999".to_string();
prov.create_resource(&role(true)).unwrap();
prov.account_id = "123456789012".to_string();
prov.create_resource(&func("foreign-fn-3", &foreign))
.expect("both roles trusting Lambda accepted");
}

#[test]
fn getatt_iam_role_arn_returns_role_arn() {
let prov = make_provisioner();
Expand Down
27 changes: 12 additions & 15 deletions crates/fakecloud-lambda/src/runtime/facade.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,22 +101,12 @@ fn pool_key_belongs_to(key: &str, function_arn: &str) -> bool {
}

/// The role the execution session is minted for: the function's role, in the
/// function's account. The two only differ when IAM is not strict and a
/// template names another account's role (commonly another emulator's
/// default `000000000000`); minting there would send the function's SDK calls
/// to an empty account instead of the one holding its resources.
/// function's account (see [`crate::service::role_in_account`]).
fn session_role_arn(role_arn: &str, function_arn: &str) -> String {
let (Some(function_account), Some(role_account)) = (
fakecloud_aws::arn::account_of(function_arn),
fakecloud_aws::arn::account_of(role_arn),
) else {
return role_arn.to_string();
};
let mut parts: Vec<&str> = role_arn.split(':').collect();
if role_account != function_account {
parts[4] = function_account;
}
parts.join(":")
match fakecloud_aws::arn::account_of(function_arn) {
Some(function_account) => crate::service::role_in_account(role_arn, function_account),
None => role_arn.to_string(),
}
}

/// Whether `entry` can take a new invocation of `deploy_id`: same code +
Expand Down Expand Up @@ -1738,6 +1728,13 @@ mod tests {
super::session_role_arn("arn:aws:iam::000000000000:role/path/r", f),
"arn:aws:iam::123456789012:role/path/r"
);
// Regression: an account-less role ARN is validated as the
// function account's role, so its session is minted there too rather
// than in the server's default account.
assert_eq!(
super::session_role_arn("arn:aws:iam:::role/r", f),
"arn:aws:iam::123456789012:role/r"
);
assert_eq!(super::session_role_arn("not-an-arn", f), "not-an-arn");
}

Expand Down
7 changes: 5 additions & 2 deletions crates/fakecloud-lambda/src/service/functions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,11 @@ impl LambdaService {
));
}

// PassRole: a role whose trust policy lets Lambda assume it, and (under
// IAM enforcement) in the caller's account.
// PassRole: under strict IAM a role in another account is refused
// outright. Otherwise the role's trust policy (read in the account its
// ARN names) must let Lambda assume it, and for a cross-account role so
// must the same-named role in the caller's account, which is the one
// the function's session is minted for.
super::validate_execution_role(
&req.account_id,
&input.role,
Expand Down
59 changes: 45 additions & 14 deletions crates/fakecloud-lambda/src/service/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -573,11 +573,13 @@ pub(crate) fn validate_environment(
/// whatever its trust policy says; `--iam soft` logs the would-be denial to
/// the IAM audit target and allows it; with IAM off (the default) it is
/// accepted, so templates carrying another emulator's default account
/// (`000000000000`) keep working. The execution session is then minted in
/// the function's account.
/// - The role's trust policy must let `lambda.amazonaws.com` assume it,
/// looked up in the caller's (function's) account, where the session is
/// minted. Always applied, as it always was on `CreateFunction`.
/// (`000000000000`) keep working.
/// - The role's trust policy must let `lambda.amazonaws.com` assume it. The
/// role checked is the one the ARN names, in the account that owns it, or
/// the caller's when the ARN names none. For another account's role the
/// execution session is minted for the same-named role in the caller's
/// account (see [`role_in_account`]), so that role's trust policy must
/// allow Lambda too. Always applied, as it always was on `CreateFunction`.
pub fn validate_execution_role(
caller_account: &str,
role_arn: &str,
Expand All @@ -604,19 +606,48 @@ pub fn validate_execution_role(
}
}
if let Some(validator) = validator {
validator
.validate(caller_account, role_arn, "lambda.amazonaws.com")
.map_err(|err| {
AwsServiceError::aws_error(
StatusCode::BAD_REQUEST,
"InvalidParameterValueException",
err.to_string(),
)
})?;
let check = |account: &str, arn: &str| {
validator
.validate(account, arn, "lambda.amazonaws.com")
.map_err(|err| {
AwsServiceError::aws_error(
StatusCode::BAD_REQUEST,
"InvalidParameterValueException",
err.to_string(),
)
})
};
let role_account = fakecloud_aws::arn::account_of(role_arn).unwrap_or(caller_account);
check(role_account, role_arn)?;
if cross_account {
// The role the function's session is actually minted for.
check(caller_account, &role_in_account(role_arn, caller_account))?;
}
}
Ok(())
}

/// `role_arn` re-homed into `account`: the role a function's execution
/// session is minted for when its configured role belongs to another account
/// (IAM off or soft). Minting in the role's own account would send the
/// function's SDK calls (commonly from templates carrying another emulator's
/// default `000000000000`) to an account that holds none of its resources.
/// An IAM role ARN naming no account (`arn:aws:iam:::role/r`) is the
/// caller's role, which is where its trust policy is checked, so it is
/// re-homed too; otherwise the session would be minted in the server's
/// default account. Anything else naming no account (not an ARN) is
/// returned unchanged.
pub(crate) fn role_in_account(role_arn: &str, account: &str) -> String {
let mut parts: Vec<&str> = role_arn.split(':').collect();
let accountless_role =
parts.len() >= 6 && parts[0] == "arn" && parts[2] == "iam" && parts[5].starts_with("role/");
if fakecloud_aws::arn::account_of(role_arn).is_none() && !accountless_role {
return role_arn.to_string();
}
parts[4] = account;
parts.join(":")
}

/// All fields of a `CreateFunction` request, already parsed and
/// defaulted. The code zip (if any) is eagerly base64-decoded so the
/// caller can hash it without doing the decode again.
Expand Down
Loading
Loading