diff --git a/crates/fakecloud-cloudformation/src/resource_provisioner/mod.rs b/crates/fakecloud-cloudformation/src/resource_provisioner/mod.rs index 239a795a7..c2a5adf64 100644 --- a/crates/fakecloud-cloudformation/src/resource_provisioner/mod.rs +++ b/crates/fakecloud-cloudformation/src/resource_provisioner/mod.rs @@ -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(); diff --git a/crates/fakecloud-lambda/src/runtime/facade.rs b/crates/fakecloud-lambda/src/runtime/facade.rs index b50c1eca8..7b5284481 100644 --- a/crates/fakecloud-lambda/src/runtime/facade.rs +++ b/crates/fakecloud-lambda/src/runtime/facade.rs @@ -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 + @@ -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"); } diff --git a/crates/fakecloud-lambda/src/service/functions.rs b/crates/fakecloud-lambda/src/service/functions.rs index 334b5e03b..b185d3d66 100644 --- a/crates/fakecloud-lambda/src/service/functions.rs +++ b/crates/fakecloud-lambda/src/service/functions.rs @@ -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, diff --git a/crates/fakecloud-lambda/src/service/mod.rs b/crates/fakecloud-lambda/src/service/mod.rs index 0aa73bd0f..e61172bdb 100644 --- a/crates/fakecloud-lambda/src/service/mod.rs +++ b/crates/fakecloud-lambda/src/service/mod.rs @@ -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, @@ -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. diff --git a/crates/fakecloud-lambda/src/service_tests.rs b/crates/fakecloud-lambda/src/service_tests.rs index 6358ce53e..7fef99ab4 100644 --- a/crates/fakecloud-lambda/src/service_tests.rs +++ b/crates/fakecloud-lambda/src/service_tests.rs @@ -4403,7 +4403,7 @@ fn cross_account_execution_role_is_refused_only_under_strict_iam() { } #[test] -fn trust_check_runs_in_the_function_account() { +fn trust_check_runs_in_the_roles_account() { let validator = RecordingTrustValidator::default(); validate_execution_role( "123456789012", @@ -4412,7 +4412,194 @@ fn trust_check_runs_in_the_function_account() { fakecloud_core::auth::IamMode::Off, ) .unwrap(); - // The session is minted in the function's account, so that is where - // the role's trust policy is looked up. - assert_eq!(*validator.accounts.lock(), vec!["123456789012".to_string()]); + // A role ARN naming no account falls back to the caller's. + validate_execution_role( + "123456789012", + "arn:aws:iam:::role/r", + Some(&validator), + fakecloud_core::auth::IamMode::Off, + ) + .unwrap(); + // Another account's role is checked in its own account, then the + // same-named role the session is minted for in the caller's. + assert_eq!( + *validator.accounts.lock(), + vec![ + "000000000000".to_string(), + "123456789012".to_string(), + "123456789012".to_string() + ] + ); +} + +#[test] +fn role_in_account_rehomes_role_arns() { + assert_eq!( + role_in_account("arn:aws:iam::000000000000:role/path/r", "123456789012"), + "arn:aws:iam::123456789012:role/path/r" + ); + assert_eq!( + role_in_account("arn:aws-cn:iam::000000000000:role/r", "123456789012"), + "arn:aws-cn:iam::123456789012:role/r" + ); + // An account-less role ARN is the caller's role: re-homed so the session + // is minted where its trust policy was checked, partition and path kept. + assert_eq!( + role_in_account("arn:aws:iam:::role/r", "123456789012"), + "arn:aws:iam::123456789012:role/r" + ); + assert_eq!( + role_in_account("arn:aws-cn:iam:::role/path/r", "123456789012"), + "arn:aws-cn:iam::123456789012:role/path/r" + ); + // Not an IAM role ARN and naming no account: left alone. + assert_eq!(role_in_account("not-an-arn", "1"), "not-an-arn"); + assert_eq!( + role_in_account("arn:aws:iam:::user/u", "1"), + "arn:aws:iam:::user/u" + ); + assert_eq!( + role_in_account("arn:aws:iam:::role", "1"), + "arn:aws:iam:::role" + ); +} + +/// Two accounts each hold a role named `app`; only those in the listed +/// accounts have a trust policy that lets Lambda assume them. +struct TrustsOnlyIn(&'static [&'static str]); + +impl fakecloud_core::auth::RoleTrustValidator for TrustsOnlyIn { + fn validate( + &self, + account_id: &str, + role_arn: &str, + service_principal: &str, + ) -> Result<(), fakecloud_core::auth::PassRoleError> { + assert_eq!(service_principal, "lambda.amazonaws.com"); + // A role is looked up in the account its ARN names; an ARN naming + // none is looked up in the caller's. + if let Some(arn_account) = fakecloud_aws::arn::account_of(role_arn) { + assert_eq!( + arn_account, account_id, + "a role is looked up in the account its ARN names" + ); + } + if self.0.contains(&account_id) { + Ok(()) + } else { + Err(fakecloud_core::auth::PassRoleError::TrustPolicyDenies { + role_arn: role_arn.to_string(), + service_principal: service_principal.to_string(), + }) + } + } +} + +#[test] +fn another_accounts_role_is_checked_against_its_own_trust_policy() { + use fakecloud_core::auth::IamMode; + let foreign = "arn:aws:iam::999999999999:role/app"; + let own = "arn:aws:iam::123456789012:role/app"; + + // The caller's `app` trusts Lambda, the foreign one does not: the + // caller's role does not vouch for the role the ARN names. + let own_trusts = TrustsOnlyIn(&["123456789012"]); + for mode in [IamMode::Off, IamMode::Soft] { + let err = validate_execution_role("123456789012", foreign, Some(&own_trusts), mode) + .expect_err("foreign untrusting role refused"); + assert_eq!(err.code(), "InvalidParameterValueException"); + assert!(err.message().contains(foreign), "{}", err.message()); + } + validate_execution_role("123456789012", own, Some(&own_trusts), IamMode::Strict).unwrap(); + // An ARN naming no account is the caller's role. + validate_execution_role( + "123456789012", + "arn:aws:iam:::role/app", + Some(&own_trusts), + IamMode::Strict, + ) + .unwrap(); + let err = validate_execution_role( + "123456789012", + "arn:aws:iam:::role/app", + Some(&TrustsOnlyIn(&["999999999999"])), + IamMode::Off, + ) + .expect_err("caller's untrusting role refused"); + assert_eq!(err.code(), "InvalidParameterValueException"); + + // The foreign role trusts Lambda, but the session is minted for the + // caller's untrusting `app`, so that one refuses it. + let foreign_trusts = TrustsOnlyIn(&["999999999999"]); + let err = validate_execution_role("123456789012", foreign, Some(&foreign_trusts), IamMode::Off) + .expect_err("role the session is minted for does not trust Lambda"); + assert_eq!(err.code(), "InvalidParameterValueException"); + assert!(err.message().contains(own), "{}", err.message()); + + // Both trust Lambda: accepted with IAM off or soft. + let both = TrustsOnlyIn(&["999999999999", "123456789012"]); + for mode in [IamMode::Off, IamMode::Soft] { + validate_execution_role("123456789012", foreign, Some(&both), mode) + .unwrap_or_else(|e| panic!("{mode}: {e}")); + } + + // Strict refuses another account's role before its trust policy is read: + // the validator is never consulted. + let recording = RecordingTrustValidator::default(); + let err = validate_execution_role("123456789012", foreign, Some(&recording), IamMode::Strict) + .expect_err("strict refuses cross-account"); + assert_eq!(err.code(), "AccessDeniedException"); + assert!( + recording.accounts.lock().is_empty(), + "trust policy looked up before the strict refusal" + ); +} + +#[tokio::test] +async fn create_and_update_function_check_both_accounts_roles() { + // `app` trusts Lambda in the caller's account only. + let svc = LambdaService::new(make_state()) + .with_role_trust_validator(Arc::new(TrustsOnlyIn(&["123456789012"]))); + let create = |name: &str, role: &str| { + make_request( + Method::POST, + "/2015-03-31/functions", + &json!({ + "FunctionName": name, + "Runtime": "python3.12", + "Role": role, + "Handler": "index.handler", + "Code": {} + }) + .to_string(), + ) + }; + let foreign = "arn:aws:iam::999999999999:role/app"; + let own = "arn:aws:iam::123456789012:role/app"; + let err = svc + .handle(create("foreign-role", foreign)) + .await + .err() + .expect("foreign untrusting role refused"); + assert_eq!(err.code(), "InvalidParameterValueException"); + svc.handle(create("own-role", own)) + .await + .expect("caller's trusting role accepted"); + + let update = |role: &str| { + make_request( + Method::PUT, + "/2015-03-31/functions/own-role/configuration", + &json!({ "Role": role }).to_string(), + ) + }; + let err = svc + .handle(update(foreign)) + .await + .err() + .expect("update to the foreign untrusting role refused"); + assert_eq!(err.code(), "InvalidParameterValueException"); + svc.handle(update(own)) + .await + .expect("update to the caller's trusting role accepted"); } diff --git a/website/content/docs/services/lambda.md b/website/content/docs/services/lambda.md index 805eb698f..1222d1593 100644 --- a/website/content/docs/services/lambda.md +++ b/website/content/docs/services/lambda.md @@ -13,7 +13,7 @@ fakecloud implements **73 of 73** Lambda operations at 100% Smithy conformance. - **23 runtimes** — Node.js (16/18/20/22/24), Python (3.8/3.9/3.10/3.11/3.12/3.13/3.14), Java (11/17/21/25), Go (1.x), Ruby (3.3/3.4), .NET (8/10), `provided.al2`, `provided.al2023` - **Event source mappings** — SQS, Kinesis, DynamoDB Streams polling loops with **`FilterCriteria`** (EventBridge-style JSON pattern, exists/prefix/suffix/equals-ignore-case/anything-but/numeric operators, SQS body decode), **`StartingPosition`** (`TRIM_HORIZON` / `LATEST` / `AT_TIMESTAMP` for Kinesis, `TRIM_HORIZON` / `LATEST` for DDB Streams), **`MaximumBatchingWindowInSeconds`** (SQS), and **`FunctionResponseTypes=[ReportBatchItemFailures]`** for SQS partial-batch failure semantics - **Layers** — create, publish, attach to functions; layer ZIP content is extracted into `/opt` of the runtime container at invoke time, so Python `import`, Node `require`, and `LD_LIBRARY_PATH` lookups resolve against attached layers exactly as on real AWS -- **Environment variables**: the function's own variables plus the environment real Lambda provides: `AWS_REGION`/`AWS_DEFAULT_REGION` (the function's region), execution-role credentials (`AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY`/`AWS_SESSION_TOKEN`, a registered session for the function's role named after the function, so SDK calls sign as `assumed-role//` and verify under `--verify-sigv4`), and `AWS_LAMBDA_FUNCTION_NAME`/`_VERSION`/`_MEMORY_SIZE`, `AWS_LAMBDA_LOG_GROUP_NAME`/`_LOG_STREAM_NAME`, `AWS_EXECUTION_ENV`. `AWS_ENDPOINT_URL` points the SDK back at fakecloud (overridable by setting it on the function). Reserved keys are rejected on `CreateFunction`/`UpdateFunctionConfiguration` with `InvalidParameterValueException`, as on AWS. The execution role must trust `lambda.amazonaws.com`; under `--iam strict` it must also be in the caller's account (`AccessDeniedException: Cross-account pass role is not allowed.` otherwise; `--iam soft` logs the would-be denial), while the default mode accepts another account's role (e.g. `000000000000` templates) and mints the session in the function's account. Applied on create, update and CloudFormation alike +- **Environment variables**: the function's own variables plus the environment real Lambda provides: `AWS_REGION`/`AWS_DEFAULT_REGION` (the function's region), execution-role credentials (`AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY`/`AWS_SESSION_TOKEN`, a registered session for the function's role named after the function, so SDK calls sign as `assumed-role//` and verify under `--verify-sigv4`), and `AWS_LAMBDA_FUNCTION_NAME`/`_VERSION`/`_MEMORY_SIZE`, `AWS_LAMBDA_LOG_GROUP_NAME`/`_LOG_STREAM_NAME`, `AWS_EXECUTION_ENV`. `AWS_ENDPOINT_URL` points the SDK back at fakecloud (overridable by setting it on the function). Reserved keys are rejected on `CreateFunction`/`UpdateFunctionConfiguration` with `InvalidParameterValueException`, as on AWS. The execution role must trust `lambda.amazonaws.com` (the trust policy read is the one of the role the ARN names, in the account that owns it, and for another account's role also that of the same-named role in the function's account, which the session is minted for); under `--iam strict` it must also be in the caller's account (`AccessDeniedException: Cross-account pass role is not allowed.` otherwise; `--iam soft` logs the would-be denial), while the default mode accepts another account's role (e.g. `000000000000` templates) and mints the session in the function's account. Applied on create, update and CloudFormation alike - **Aliases and versions** — publish, point aliases at versions; alias-based weighted routing (`RoutingConfig`) is enforced at invoke time so traffic splits between versions exactly as on AWS - **Concurrency controls** — reserved concurrency enforced at invocation time: per-function reservation caps in-flight invocations and excess requests are rejected with `TooManyRequestsException` (HTTP 429) and `Reason=ReservedFunctionConcurrentInvocationLimitExceeded` - **`UpdateFunctionCode` from S3** — `S3Bucket`/`S3Key`/`S3ObjectVersion` fetches the ZIP from the fakecloud S3 implementation; the stored `CodeSha256` is the real SHA-256 of the fetched bytes