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
33 changes: 28 additions & 5 deletions crates/fakecloud-core/src/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -710,16 +710,39 @@ pub async fn dispatch(
// through to the handler.
}
} else {
// Service opted in but didn't return an IamAction
// for this specific operation — programming bug,
// surface it loudly in soft/strict mode so it's
// visible during rollout.
// Service opted in via `iam_enforceable()` but its
// `iam_action_for` returned no `IamAction` for this
// specific operation (e.g. S3's `s3_detect_action`
// has `_ => return None` arms for unrecognized
// sub-resources). Under strict enforcement that must
// fail closed: an operation we cannot map to an IAM
// action cannot be authorized, so serving it would be
// a fail-open bypass of `--iam strict`. Deny by
// default. In soft mode we preserve the historical
// warn-and-allow so an incomplete mapping surfaces
// during rollout without blocking traffic.
tracing::warn!(
target: "fakecloud::iam::audit",
service = %detected.service,
action = %aws_request.action,
"service is iam_enforceable but has no IamAction mapping for this action; skipping evaluation"
mode = %config.iam_mode,
request_id = %request_id,
"service is iam_enforceable but has no IamAction mapping for this action; denying under strict, allowing under soft"
);
if config.iam_mode.is_strict() {
return build_error_response(
StatusCode::FORBIDDEN,
"AccessDeniedException",
&format!(
"User: {} is not authorized to perform: {}: no IAM action mapping exists for this operation, so it cannot be authorized under strict IAM enforcement",
principal.arn, aws_request.action,
),
&request_id,
detected.protocol,
);
}
// Soft mode: audit log emitted; fall through to the
// handler.
}
}
} else if aws_request.access_key_id.is_none() {
Expand Down
91 changes: 91 additions & 0 deletions crates/fakecloud-e2e/tests/iam_enforcement.rs
Original file line number Diff line number Diff line change
Expand Up @@ -907,6 +907,97 @@ async fn bucket_policy_identity_allow_no_bucket_policy_still_works() {
.unwrap();
}

/// A hand-crafted signed Authorization header carrying `akid`'s access key in
/// the S3 credential scope. With SigV4 verification off the dummy signature is
/// never checked, but dispatch still parses the access key and resolves the
/// principal — letting a test drive requests that the AWS SDK would never
/// emit (here, an S3 sub-resource form with no IAM-action mapping).
fn s3_signed_auth_header(akid: &str) -> String {
format!(
"AWS4-HMAC-SHA256 Credential={akid}/20240101/us-east-1/s3/aws4_request, \
SignedHeaders=host, Signature=deadbeef"
)
}

#[tokio::test]
async fn strict_mode_denies_unmapped_enforceable_action() {
// Fix 5.2: strict enforcement must fail closed when an `iam_enforceable`
// service returns no `IamAction` for an operation. `POST /bucket?acl` is
// not a valid S3 op (ACL is GET/PUT only), so `s3_detect_action` returns
// None — the enforceable-but-unmapped branch. Previously dispatch warned
// and fell through to the handler, running the op with no policy check.
//
// SigV4 verification is left OFF so the hand-crafted request reaches the
// IAM layer (a bad signature would otherwise 403 earlier, masking the
// behavior under test).
let server = TestServer::start_with_env(&[("FAKECLOUD_IAM", "strict")]).await;

let boot = sdk_config_with(&server, "test", "test").await;
aws_sdk_s3::Client::new(&boot)
.create_bucket()
.bucket("unmapped-probe")
.send()
.await
.unwrap();

let (akid, _secret) = bootstrap_user(&server, "unmapped_user").await;
// Allow-all identity policy: any *mapped* action would be permitted, so a
// 403 here can only come from the missing IAM-action mapping, not a policy
// deny. This isolates the fail-closed behavior.
attach_inline_policy(
&server,
"unmapped_user",
"AllowEverything",
r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"*","Resource":"*"}]}"#,
)
.await;

let http = reqwest::Client::new();
let resp = http
.post(format!("{}/unmapped-probe?acl", server.endpoint()))
.header("authorization", s3_signed_auth_header(&akid))
.send()
.await
.unwrap();
assert_eq!(
resp.status(),
403,
"strict mode must deny an enforceable action with no IAM-action mapping (fail closed)"
);
}

#[tokio::test]
async fn soft_mode_allows_unmapped_enforceable_action_through() {
// Counterpart guarantee to `strict_mode_denies_unmapped_enforceable_action`:
// the new deny is gated on strict mode only. In soft mode the same unmapped
// enforceable action is NOT denied by the IAM layer — it warns and falls
// through to the handler, preserving historical rollout behavior.
let server = TestServer::start_with_env(&[("FAKECLOUD_IAM", "soft")]).await;

let boot = sdk_config_with(&server, "test", "test").await;
aws_sdk_s3::Client::new(&boot)
.create_bucket()
.bucket("unmapped-soft-probe")
.send()
.await
.unwrap();

let (akid, _secret) = bootstrap_user(&server, "unmapped_soft_user").await;

let http = reqwest::Client::new();
let resp = http
.post(format!("{}/unmapped-soft-probe?acl", server.endpoint()))
.header("authorization", s3_signed_auth_header(&akid))
.send()
.await
.unwrap();
assert_ne!(
resp.status(),
403,
"soft mode must not deny an unmapped enforceable action; it falls through to the handler"
);
}

#[tokio::test]
async fn bucket_policy_principal_wildcard_grants_any_user() {
// Public bucket idiom: `"Principal": "*"`. Any non-root caller
Expand Down
Loading