From 96fff64a033b722c537de30b61dc4c3672c695b0 Mon Sep 17 00:00:00 2001 From: Lucas Vieira Date: Thu, 1 Oct 2026 08:30:51 -0300 Subject: [PATCH 1/6] fix(lambda): read the execution role's trust policy in the role's account validate_execution_role looked a role's trust policy up in the caller's account, so for another account's role ARN (allowed with IAM off or soft) a same-named role in the caller's account was checked instead of the role the ARN names. Resolve the role in the account its ARN names, falling back to the caller's when it names none, as ECS's validate_task_role does. CreateFunction, UpdateFunctionConfiguration and CloudFormation AWS::Lambda::Function share the check. Strict mode still refuses another account's role before the trust policy is read. Closes #2634 --- .../src/resource_provisioner/mod.rs | 67 ++++++++++ .../fakecloud-lambda/src/service/functions.rs | 4 +- crates/fakecloud-lambda/src/service/mod.rs | 14 +- crates/fakecloud-lambda/src/service_tests.rs | 124 +++++++++++++++++- website/content/docs/services/lambda.md | 2 +- 5 files changed, 198 insertions(+), 13 deletions(-) diff --git a/crates/fakecloud-cloudformation/src/resource_provisioner/mod.rs b/crates/fakecloud-cloudformation/src/resource_provisioner/mod.rs index 239a795a7..98a6d6b1a 100644 --- a/crates/fakecloud-cloudformation/src/resource_provisioner/mod.rs +++ b/crates/fakecloud-cloudformation/src/resource_provisioner/mod.rs @@ -8633,6 +8633,73 @@ mod tests { ); } + #[test] + fn lambda_function_role_trust_is_read_in_the_roles_account() { + // 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: its untrusting namesake in the + // stack's account does not decide the outcome. + prov.create_resource(&func("foreign-fn", &foreign)) + .expect("foreign role trusting Lambda accepted"); + 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. + 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"); + } + #[test] fn getatt_iam_role_arn_returns_role_arn() { let prov = make_provisioner(); diff --git a/crates/fakecloud-lambda/src/service/functions.rs b/crates/fakecloud-lambda/src/service/functions.rs index 334b5e03b..6295e99b7 100644 --- a/crates/fakecloud-lambda/src/service/functions.rs +++ b/crates/fakecloud-lambda/src/service/functions.rs @@ -26,8 +26,8 @@ impl LambdaService { )); } - // PassRole: a role whose trust policy lets Lambda assume it, and (under - // IAM enforcement) in the caller's account. + // PassRole: a role whose trust policy (read in the role's own account) + // lets Lambda assume it, and (under strict IAM) in the caller's account. 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..d7152370d 100644 --- a/crates/fakecloud-lambda/src/service/mod.rs +++ b/crates/fakecloud-lambda/src/service/mod.rs @@ -573,11 +573,12 @@ 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: a same-named role in the caller's +/// account is a different role. Always applied, as it always was on +/// `CreateFunction`. pub fn validate_execution_role( caller_account: &str, role_arn: &str, @@ -604,8 +605,9 @@ pub fn validate_execution_role( } } if let Some(validator) = validator { + let role_account = fakecloud_aws::arn::account_of(role_arn).unwrap_or(caller_account); validator - .validate(caller_account, role_arn, "lambda.amazonaws.com") + .validate(role_account, role_arn, "lambda.amazonaws.com") .map_err(|err| { AwsServiceError::aws_error( StatusCode::BAD_REQUEST, diff --git a/crates/fakecloud-lambda/src/service_tests.rs b/crates/fakecloud-lambda/src/service_tests.rs index 6358ce53e..4d80846c2 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,123 @@ 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(); + assert_eq!( + *validator.accounts.lock(), + vec!["000000000000".to_string(), "123456789012".to_string()] + ); +} + +/// Two accounts each hold a role named `app`; only the one in `trusting` +/// has a trust policy that lets Lambda assume it. +struct TrustsOnlyIn(&'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"); + if account_id == self.0 { + 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 role's own account trusts Lambda; the caller's untrusting + // same-named role must not decide the outcome. + let foreign_trusts = TrustsOnlyIn("999999999999"); + for mode in [IamMode::Off, IamMode::Soft] { + validate_execution_role("123456789012", foreign, Some(&foreign_trusts), mode) + .unwrap_or_else(|e| panic!("{mode}: checked in the role's account: {e}")); + } + let err = validate_execution_role("123456789012", own, Some(&foreign_trusts), IamMode::Off) + .expect_err("caller's own untrusting role refused"); + assert_eq!(err.code(), "InvalidParameterValueException"); + + // And the other way round: the caller's role trusting Lambda does not + // vouch for the foreign one. + let own_trusts = TrustsOnlyIn("123456789012"); + let err = validate_execution_role("123456789012", foreign, Some(&own_trusts), IamMode::Off) + .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(); + + // Strict refuses another account's role before its trust policy is read. + let err = validate_execution_role( + "123456789012", + foreign, + Some(&foreign_trusts), + IamMode::Strict, + ) + .expect_err("strict refuses cross-account"); + assert_eq!(err.code(), "AccessDeniedException"); +} + +#[tokio::test] +async fn create_and_update_function_check_the_roles_own_account() { + let svc = LambdaService::new(make_state()) + .with_role_trust_validator(Arc::new(TrustsOnlyIn("999999999999"))); + 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"; + svc.handle(create("foreign-role", foreign)) + .await + .expect("foreign role trusting Lambda accepted"); + let err = svc + .handle(create("own-role", own)) + .await + .err() + .expect("caller's untrusting role refused"); + assert_eq!(err.code(), "InvalidParameterValueException"); + + let update = |role: &str| { + make_request( + Method::PUT, + "/2015-03-31/functions/foreign-role/configuration", + &json!({ "Role": role }).to_string(), + ) + }; + let err = svc + .handle(update(own)) + .await + .err() + .expect("update to the caller's untrusting role refused"); + assert_eq!(err.code(), "InvalidParameterValueException"); + svc.handle(update(foreign)) + .await + .expect("update to the foreign trusting role accepted"); } diff --git a/website/content/docs/services/lambda.md b/website/content/docs/services/lambda.md index 805eb698f..3a926e47c 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); 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 From 39550b27b49db33012f755777e3b922a86454c38 Mon Sep 17 00:00:00 2001 From: Lucas Vieira Date: Thu, 1 Oct 2026 08:44:17 -0300 Subject: [PATCH 2/6] test(lambda): prove strict mode refuses before reading the trust policy --- crates/fakecloud-lambda/src/service_tests.rs | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/crates/fakecloud-lambda/src/service_tests.rs b/crates/fakecloud-lambda/src/service_tests.rs index 4d80846c2..0d6de93b8 100644 --- a/crates/fakecloud-lambda/src/service_tests.rs +++ b/crates/fakecloud-lambda/src/service_tests.rs @@ -4474,15 +4474,16 @@ fn another_accounts_role_is_checked_against_its_own_trust_policy() { assert!(err.message().contains(foreign), "{}", err.message()); validate_execution_role("123456789012", own, Some(&own_trusts), IamMode::Strict).unwrap(); - // Strict refuses another account's role before its trust policy is read. - let err = validate_execution_role( - "123456789012", - foreign, - Some(&foreign_trusts), - IamMode::Strict, - ) - .expect_err("strict refuses cross-account"); + // 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] From 9ac81b2bdb59b18cd1cb5f895f17adedf7c41686 Mon Sep 17 00:00:00 2001 From: Lucas Vieira Date: Thu, 1 Oct 2026 08:52:49 -0300 Subject: [PATCH 3/6] fix(lambda): also check the role the execution session is minted for A cross-account execution role is minted in the function's account (as the same-named role there), so with IAM off or soft that role's trust policy must allow Lambda too, not only the one the ARN names. Share the re-homing between the trust check and the session minting. --- .../src/resource_provisioner/mod.rs | 24 +++-- crates/fakecloud-lambda/src/runtime/facade.rs | 20 +--- crates/fakecloud-lambda/src/service/mod.rs | 47 +++++--- crates/fakecloud-lambda/src/service_tests.rs | 100 ++++++++++++------ website/content/docs/services/lambda.md | 2 +- 5 files changed, 127 insertions(+), 66 deletions(-) diff --git a/crates/fakecloud-cloudformation/src/resource_provisioner/mod.rs b/crates/fakecloud-cloudformation/src/resource_provisioner/mod.rs index 98a6d6b1a..c2a5adf64 100644 --- a/crates/fakecloud-cloudformation/src/resource_provisioner/mod.rs +++ b/crates/fakecloud-cloudformation/src/resource_provisioner/mod.rs @@ -8634,7 +8634,7 @@ mod tests { } #[test] - fn lambda_function_role_trust_is_read_in_the_roles_account() { + 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| { @@ -8676,15 +8676,20 @@ mod tests { }), ) }; - // The foreign role trusts Lambda: its untrusting namesake in the - // stack's account does not decide the outcome. - prov.create_resource(&func("foreign-fn", &foreign)) - .expect("foreign role trusting Lambda accepted"); + // 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. + // 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(); @@ -8698,6 +8703,13 @@ mod tests { ); 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] diff --git a/crates/fakecloud-lambda/src/runtime/facade.rs b/crates/fakecloud-lambda/src/runtime/facade.rs index b50c1eca8..9188e04ad 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 + diff --git a/crates/fakecloud-lambda/src/service/mod.rs b/crates/fakecloud-lambda/src/service/mod.rs index d7152370d..1044b4fd3 100644 --- a/crates/fakecloud-lambda/src/service/mod.rs +++ b/crates/fakecloud-lambda/src/service/mod.rs @@ -576,9 +576,10 @@ pub(crate) fn validate_environment( /// (`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: a same-named role in the caller's -/// account is a different role. Always applied, as it always was on -/// `CreateFunction`. +/// 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, @@ -605,20 +606,42 @@ pub fn validate_execution_role( } } if let Some(validator) = validator { + 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); - validator - .validate(role_account, role_arn, "lambda.amazonaws.com") - .map_err(|err| { - AwsServiceError::aws_error( - StatusCode::BAD_REQUEST, - "InvalidParameterValueException", - err.to_string(), - ) - })?; + 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 ARN naming no account is returned unchanged. +pub(crate) fn role_in_account(role_arn: &str, account: &str) -> String { + if fakecloud_aws::arn::account_of(role_arn).is_none() { + return role_arn.to_string(); + } + let mut parts: Vec<&str> = role_arn.split(':').collect(); + 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 0d6de93b8..ff14ea73f 100644 --- a/crates/fakecloud-lambda/src/service_tests.rs +++ b/crates/fakecloud-lambda/src/service_tests.rs @@ -4420,15 +4420,38 @@ fn trust_check_runs_in_the_roles_account() { 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()] + vec![ + "000000000000".to_string(), + "123456789012".to_string(), + "123456789012".to_string() + ] ); } -/// Two accounts each hold a role named `app`; only the one in `trusting` -/// has a trust policy that lets Lambda assume it. -struct TrustsOnlyIn(&'static str); +#[test] +fn role_in_account_rehomes_only_arns_naming_an_account() { + 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::123456789012:role/r", "123456789012"), + "arn:aws-cn:iam::123456789012:role/r" + ); + assert_eq!( + role_in_account("arn:aws:iam:::role/r", "1"), + "arn:aws:iam:::role/r" + ); + assert_eq!(role_in_account("not-an-arn", "1"), "not-an-arn"); +} + +/// 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( @@ -4438,7 +4461,12 @@ impl fakecloud_core::auth::RoleTrustValidator for TrustsOnlyIn { service_principal: &str, ) -> Result<(), fakecloud_core::auth::PassRoleError> { assert_eq!(service_principal, "lambda.amazonaws.com"); - if account_id == self.0 { + assert_eq!( + fakecloud_aws::arn::account_of(role_arn), + Some(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 { @@ -4454,25 +4482,32 @@ 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 role's own account trusts Lambda; the caller's untrusting - // same-named role must not decide the outcome. - let foreign_trusts = TrustsOnlyIn("999999999999"); + + // 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] { - validate_execution_role("123456789012", foreign, Some(&foreign_trusts), mode) - .unwrap_or_else(|e| panic!("{mode}: checked in the role's account: {e}")); + 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()); } - let err = validate_execution_role("123456789012", own, Some(&foreign_trusts), IamMode::Off) - .expect_err("caller's own untrusting role refused"); - assert_eq!(err.code(), "InvalidParameterValueException"); + validate_execution_role("123456789012", own, Some(&own_trusts), IamMode::Strict).unwrap(); - // And the other way round: the caller's role trusting Lambda does not - // vouch for the foreign one. - let own_trusts = TrustsOnlyIn("123456789012"); - let err = validate_execution_role("123456789012", foreign, Some(&own_trusts), IamMode::Off) - .expect_err("foreign untrusting role refused"); + // 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(foreign), "{}", err.message()); - validate_execution_role("123456789012", own, Some(&own_trusts), IamMode::Strict).unwrap(); + 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. @@ -4487,9 +4522,10 @@ fn another_accounts_role_is_checked_against_its_own_trust_policy() { } #[tokio::test] -async fn create_and_update_function_check_the_roles_own_account() { +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("999999999999"))); + .with_role_trust_validator(Arc::new(TrustsOnlyIn(&["123456789012"]))); let create = |name: &str, role: &str| { make_request( Method::POST, @@ -4506,30 +4542,30 @@ async fn create_and_update_function_check_the_roles_own_account() { }; let foreign = "arn:aws:iam::999999999999:role/app"; let own = "arn:aws:iam::123456789012:role/app"; - svc.handle(create("foreign-role", foreign)) - .await - .expect("foreign role trusting Lambda accepted"); let err = svc - .handle(create("own-role", own)) + .handle(create("foreign-role", foreign)) .await .err() - .expect("caller's untrusting role refused"); + .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/foreign-role/configuration", + "/2015-03-31/functions/own-role/configuration", &json!({ "Role": role }).to_string(), ) }; let err = svc - .handle(update(own)) + .handle(update(foreign)) .await .err() - .expect("update to the caller's untrusting role refused"); + .expect("update to the foreign untrusting role refused"); assert_eq!(err.code(), "InvalidParameterValueException"); - svc.handle(update(foreign)) + svc.handle(update(own)) .await - .expect("update to the foreign trusting role accepted"); + .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 3a926e47c..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` (the trust policy read is the one of the role the ARN names, in the account that owns it); 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 From 5b2218fd35a2dcc19c48a024903b17e5d520f02a Mon Sep 17 00:00:00 2001 From: Lucas Vieira Date: Thu, 1 Oct 2026 09:10:58 -0300 Subject: [PATCH 4/6] test(lambda): re-home a foreign aws-cn ARN and accept account-less roles in the trust double --- crates/fakecloud-lambda/src/service_tests.rs | 31 ++++++++++++++++---- 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/crates/fakecloud-lambda/src/service_tests.rs b/crates/fakecloud-lambda/src/service_tests.rs index ff14ea73f..83b2f46ee 100644 --- a/crates/fakecloud-lambda/src/service_tests.rs +++ b/crates/fakecloud-lambda/src/service_tests.rs @@ -4439,7 +4439,7 @@ fn role_in_account_rehomes_only_arns_naming_an_account() { "arn:aws:iam::123456789012:role/path/r" ); assert_eq!( - role_in_account("arn:aws-cn:iam::123456789012:role/r", "123456789012"), + role_in_account("arn:aws-cn:iam::000000000000:role/r", "123456789012"), "arn:aws-cn:iam::123456789012:role/r" ); assert_eq!( @@ -4461,11 +4461,14 @@ impl fakecloud_core::auth::RoleTrustValidator for TrustsOnlyIn { service_principal: &str, ) -> Result<(), fakecloud_core::auth::PassRoleError> { assert_eq!(service_principal, "lambda.amazonaws.com"); - assert_eq!( - fakecloud_aws::arn::account_of(role_arn), - Some(account_id), - "a role is looked up in the account its ARN names" - ); + // 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 { @@ -4493,6 +4496,22 @@ fn another_accounts_role_is_checked_against_its_own_trust_policy() { 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. From bbe5f0fe1ec0bf0ab323d3c31ff9f09b2e5ba067 Mon Sep 17 00:00:00 2001 From: Lucas Vieira Date: Thu, 1 Oct 2026 14:25:13 -0300 Subject: [PATCH 5/6] fix(lambda): mint an account-less execution role in the function's account --- crates/fakecloud-lambda/src/runtime/facade.rs | 7 +++++++ crates/fakecloud-lambda/src/service/mod.rs | 12 ++++++++--- crates/fakecloud-lambda/src/service_tests.rs | 21 ++++++++++++++++--- 3 files changed, 34 insertions(+), 6 deletions(-) diff --git a/crates/fakecloud-lambda/src/runtime/facade.rs b/crates/fakecloud-lambda/src/runtime/facade.rs index 9188e04ad..7b5284481 100644 --- a/crates/fakecloud-lambda/src/runtime/facade.rs +++ b/crates/fakecloud-lambda/src/runtime/facade.rs @@ -1728,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/mod.rs b/crates/fakecloud-lambda/src/service/mod.rs index 1044b4fd3..e61172bdb 100644 --- a/crates/fakecloud-lambda/src/service/mod.rs +++ b/crates/fakecloud-lambda/src/service/mod.rs @@ -632,12 +632,18 @@ pub fn validate_execution_role( /// (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 ARN naming no account is returned unchanged. +/// 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 { - if fakecloud_aws::arn::account_of(role_arn).is_none() { + 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(); } - let mut parts: Vec<&str> = role_arn.split(':').collect(); parts[4] = account; parts.join(":") } diff --git a/crates/fakecloud-lambda/src/service_tests.rs b/crates/fakecloud-lambda/src/service_tests.rs index 83b2f46ee..7fef99ab4 100644 --- a/crates/fakecloud-lambda/src/service_tests.rs +++ b/crates/fakecloud-lambda/src/service_tests.rs @@ -4433,7 +4433,7 @@ fn trust_check_runs_in_the_roles_account() { } #[test] -fn role_in_account_rehomes_only_arns_naming_an_account() { +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" @@ -4442,11 +4442,26 @@ fn role_in_account_rehomes_only_arns_naming_an_account() { 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", "1"), - "arn:aws:iam:::role/r" + 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 From e54f3035bc3bb9b909a9f142c5de147b6e8d14ac Mon Sep 17 00:00:00 2001 From: Lucas Vieira Date: Thu, 1 Oct 2026 14:31:14 -0300 Subject: [PATCH 6/6] docs(lambda): describe both execution-role trust checks at the call site --- crates/fakecloud-lambda/src/service/functions.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/fakecloud-lambda/src/service/functions.rs b/crates/fakecloud-lambda/src/service/functions.rs index 6295e99b7..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 (read in the role's own account) - // lets Lambda assume it, and (under strict IAM) 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,