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
21 changes: 21 additions & 0 deletions crates/fakecloud-aws/src/arn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,13 @@ pub fn arn_resource<'a>(arn: &'a str, service: &str) -> Option<&'a str> {
rest.strip_prefix(service)?.strip_prefix(':')
}

/// The account an ARN names (`arn:<partition>:<service>:<region>:<account>:...`),
/// `None` when `arn` is not an ARN or names no account.
pub fn account_of(arn: &str) -> Option<&str> {
let rest = arn.strip_prefix("arn:")?;
rest.split(':').nth(3).filter(|a| !a.is_empty())
}

/// The partition an ARN names (`arn:<partition>:...`), `aws` when it names
/// none.
pub fn partition_of(arn: &str) -> &str {
Expand Down Expand Up @@ -338,4 +345,18 @@ mod tests {
assert_eq!(partition_for("us-isof-south-1"), "aws-iso-f");
assert_eq!(partition_for("eu-isoe-west-1"), "aws-iso-e");
}

#[test]
fn account_of_reads_the_account_field() {
assert_eq!(
account_of("arn:aws:iam::123456789012:role/r"),
Some("123456789012")
);
assert_eq!(
account_of("arn:aws-cn:lambda:cn-north-1:000000000000:function:f:1"),
Some("000000000000")
);
assert_eq!(account_of("arn:aws:s3:::bucket"), None);
assert_eq!(account_of("not-an-arn"), None);
}
}
1 change: 1 addition & 0 deletions crates/fakecloud-cloudformation/src/extras.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3086,6 +3086,7 @@ pub(crate) mod tests {
appconfig: shared::<fakecloud_appconfig::AppConfigState>(),
delivery: Arc::new(DeliveryBus::new()),
lambda_runtime: None,
iam_mode: Default::default(),
rds_runtime: None,
ec2_runtime: None,
ecs_runtime: None,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ impl ResourceProvisioner {
cloudformation_state: self.cloudformation_state.clone(),
delivery: self.delivery.clone(),
lambda_runtime: self.lambda_runtime.clone(),
iam_mode: self.iam_mode,
rds_runtime: self.rds_runtime.clone(),
ec2_runtime: self.ec2_runtime.clone(),
ecs_runtime: self.ecs_runtime.clone(),
Expand Down
17 changes: 17 additions & 0 deletions crates/fakecloud-cloudformation/src/resource_provisioner/lambda.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ impl ResourceProvisioner {
.unwrap_or_else(|| self.physical_name(resource));

let cfg = parse_lambda_function_props(props)?;
self.validate_lambda_execution_role(&cfg.role)?;
let function_arn =
fakecloud_lambda::function_arn(&self.region, &self.account_id, &function_name);

Expand Down Expand Up @@ -169,6 +170,21 @@ impl ResourceProvisioner {
.with("Version", "$LATEST"))
}

/// The same execution-role checks `CreateFunction` runs: a role whose
/// trust policy lets Lambda assume it, in the stack's account under IAM
/// enforcement.
fn validate_lambda_execution_role(&self, role_arn: &str) -> Result<(), String> {
let validator =
fakecloud_iam::pass_role::IamRoleTrustValidator::new(self.iam_state.clone());
fakecloud_lambda::validate_execution_role(
&self.account_id,
role_arn,
Some(&validator),
self.iam_mode,
)
.map_err(|e| e.to_string())
}

/// Apply a CFN template-driven update to an existing Lambda function.
/// Mirrors `UpdateFunctionConfiguration` + `UpdateFunctionCode`:
/// rewrite mutable configuration fields from the new template, re-hash
Expand All @@ -184,6 +200,7 @@ impl ResourceProvisioner {
let props = &resource.properties;
let function_name = existing.physical_id.clone();
let cfg = parse_lambda_function_props(props)?;
self.validate_lambda_execution_role(&cfg.role)?;

let new_code_zip = if cfg.code_zip.is_some() {
cfg.code_zip.clone()
Expand Down
74 changes: 73 additions & 1 deletion crates/fakecloud-cloudformation/src/resource_provisioner/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -524,6 +524,12 @@ fn parse_lambda_function_props(props: &serde_json::Value) -> Result<LambdaFuncti
.collect::<BTreeMap<String, String>>()
})
.unwrap_or_default();
if let Some(message) =
fakecloud_lambda::runtime::environment::reserved_keys_message(&environment)
{
// Same `<code>: <message>` shape as the execution-role failures.
return Err(format!("InvalidParameterValueException: {message}"));
}

// CFN tags ride as `[{Key, Value}, ...]`; flatten to the map shape
// the lambda crate stores tags in.
Expand Down Expand Up @@ -981,6 +987,8 @@ pub struct ResourceProvisioner {
/// images (see `CloudFormationDeps::lambda_runtime`). `None` outside a
/// configured runtime (e.g. unit tests).
pub lambda_runtime: Option<Arc<fakecloud_lambda::runtime::ContainerRuntime>>,
/// IAM enforcement mode (see `CloudFormationDeps::iam_mode`).
pub iam_mode: fakecloud_core::auth::IamMode,
/// Container runtimes for stateful services whose CFN-provisioned resources
/// must be backed by REAL containers. See `CloudFormationDeps`. `None`
/// (no Docker/Podman, e.g. CI/unit tests) keeps metadata-only provisioning.
Expand Down Expand Up @@ -4268,6 +4276,7 @@ mod tests {
)),
delivery: Arc::new(DeliveryBus::new()),
lambda_runtime: None,
iam_mode: Default::default(),
rds_runtime: None,
ec2_runtime: None,
ecs_runtime: None,
Expand Down Expand Up @@ -8110,7 +8119,14 @@ mod tests {
"MyRole",
serde_json::json!({
"RoleName": "my-role",
"AssumeRolePolicyDocument": {"Version": "2012-10-17", "Statement": []}
"AssumeRolePolicyDocument": {
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": {"Service": "lambda.amazonaws.com"},
"Action": "sts:AssumeRole"
}]
}
}),
);
let role_sr = prov.create_resource(&role).unwrap();
Expand All @@ -8131,6 +8147,62 @@ mod tests {
assert!(arn.contains(":function:my-fn"));
}

#[test]
fn lambda_function_role_runs_the_create_function_checks() {
let prov = make_provisioner();
let untrusted = make_resource(
"AWS::IAM::Role",
"Untrusted",
serde_json::json!({
"RoleName": "untrusted",
"AssumeRolePolicyDocument": {"Version": "2012-10-17", "Statement": []}
}),
);
let untrusted = prov.create_resource(&untrusted).unwrap();
let func = |role: &str| {
make_resource(
"AWS::Lambda::Function",
"Fn",
serde_json::json!({
"FunctionName": "checked-fn",
"Runtime": "python3.12",
"Handler": "index.handler",
"Role": role,
"Code": {"ZipFile": "def handler(e,c): return e"}
}),
)
};
let err = prov
.create_resource(&func(&untrusted.physical_id))
.unwrap_err();
assert!(err.contains("InvalidParameterValueException"), "{err}");

// Another account's role: accepted with IAM enforcement off (the
// default), refused like AWS with it on.
prov.create_resource(&func("arn:aws:iam::999999999999:role/elsewhere"))
.expect("default mode accepts another account's role");
// Reserved environment keys fail with the Lambda error code too.
let mut reserved = func("arn:aws:iam::123456789012:role/r");
reserved.properties["FunctionName"] = serde_json::json!("reserved-fn");
reserved.properties["Environment"] =
serde_json::json!({"Variables": {"AWS_REGION": "eu-west-1"}});
let err = prov.create_resource(&reserved).unwrap_err();
assert!(
err.starts_with("InvalidParameterValueException: ")
&& err.ends_with("Reserved keys used in this request: AWS_REGION"),
"{err}"
);
let mut enforcing = make_provisioner();
enforcing.iam_mode = fakecloud_core::auth::IamMode::Strict;
let err = enforcing
.create_resource(&func("arn:aws:iam::999999999999:role/elsewhere"))
.unwrap_err();
assert!(
err.contains("AccessDeniedException") && err.contains("Cross-account pass role"),
"{err}"
);
}

#[test]
fn getatt_iam_role_arn_returns_role_arn() {
let prov = make_provisioner();
Expand Down
5 changes: 5 additions & 0 deletions crates/fakecloud-cloudformation/src/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -580,6 +580,9 @@ pub struct CloudFormationDeps {
/// runtime is configured — provisioning still works, the first Invoke just
/// falls back to a cold pull.
pub lambda_runtime: Option<Arc<fakecloud_lambda::runtime::ContainerRuntime>>,
/// IAM enforcement mode. `AWS::Lambda::Function` only refuses a
/// cross-account execution role (as AWS does) while it is on.
pub iam_mode: fakecloud_core::auth::IamMode,
/// Container runtimes for the stateful services whose CFN-provisioned
/// resources must be backed by REAL containers (not phantom metadata).
/// When present, the provisioner inserts the resource record synchronously
Expand Down Expand Up @@ -1229,6 +1232,7 @@ impl CloudFormationService {
cloudformation_state: self.state.clone(),
delivery: self.deps.delivery.clone(),
lambda_runtime: self.deps.lambda_runtime.clone(),
iam_mode: self.deps.iam_mode,
rds_runtime: self.deps.rds_runtime.clone(),
ec2_runtime: self.deps.ec2_runtime.clone(),
ecs_runtime: self.deps.ecs_runtime.clone(),
Expand Down Expand Up @@ -4422,6 +4426,7 @@ mod tests {
appconfig: mas(),
delivery: Arc::new(DeliveryBus::new()),
lambda_runtime: None,
iam_mode: Default::default(),
rds_runtime: None,
ec2_runtime: None,
ecs_runtime: None,
Expand Down
34 changes: 34 additions & 0 deletions crates/fakecloud-core/src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -618,6 +618,40 @@ pub trait RoleTrustValidator: Send + Sync {
) -> Result<(), PassRoleError>;
}

/// Temporary credentials for an assumed-role session, as a compute service
/// hands them to the code it runs (Lambda's execution-role environment, for
/// example).
#[derive(Clone, Debug)]
pub struct SessionCredentials {
pub access_key_id: String,
pub secret_access_key: String,
pub session_token: String,
pub expiration: DateTime<Utc>,
/// Account the session is registered under, so it can be revoked there.
pub account_id: String,
}

/// Issues assumed-role session credentials on behalf of a compute service,
/// registered so that requests signed with them resolve to
/// `arn:<partition>:sts::<account>:assumed-role/<role>/<session>` (and verify
/// under `--verify-sigv4`). Implemented over IAM state; services that run
/// user code under a role take it as an optional hook so they stay decoupled
/// from the IAM crate.
pub trait SessionCredentialIssuer: Send + Sync {
/// Mint credentials for `role_arn` with the given session name, valid for
/// `duration`.
fn issue(
&self,
role_arn: &str,
session_name: &str,
duration: chrono::Duration,
) -> SessionCredentials;

/// Unregister credentials once the code they were issued to has stopped.
/// Idempotent.
fn revoke(&self, credentials: &SessionCredentials);
}

/// Composite [`ResourcePolicyProvider`] that delegates to a list of
/// sub-providers in order, returning the first `Some` hit.
///
Expand Down
Loading
Loading