Skip to main content

fakecloud_core/
dispatch.rs

1use axum::body::Body;
2use axum::extract::{ConnectInfo, Extension, Query};
3use axum::http::{Request, StatusCode};
4use axum::response::Response;
5use bytes::Bytes;
6use std::collections::HashMap;
7use std::net::SocketAddr;
8use std::sync::Arc;
9
10use crate::auth::{
11    is_root_bypass, ConditionContext, CredentialResolver, IamMode, IamPolicyEvaluator,
12    InternalCaller, Principal, PrincipalType, ResourcePolicyProvider,
13};
14use crate::protocol::{self, AwsProtocol};
15use crate::registry::ServiceRegistry;
16use crate::service::{AwsRequest, ResponseBody};
17
18/// Pins an in-process request to one REST service; see [`dispatch_to_service`].
19/// Private, so only this module can attach it.
20#[derive(Clone, Copy, Debug)]
21struct PinnedService(&'static str);
22
23/// Dispatch an in-process request straight to one REST-protocol service
24/// (`"s3"`), bypassing the HTTP router and service detection.
25///
26/// The CloudFront data plane fetches an S3 origin this way: the request is by
27/// construction an S3 request addressed to the origin bucket (via its `Host`),
28/// so no viewer path (`/_fakecloud/*`, `/latest/*`, ...) can reach one of
29/// fakecloud's own routes, and no viewer header (`X-Amz-Target`, an
30/// `Authorization` scoped to another service, `?Action=`) can steer it to
31/// another service. Authentication and IAM enforcement run exactly as for any
32/// request, including an [`InternalCaller`] extension. The source address is
33/// the request's `ConnectInfo<SocketAddr>` extension, or loopback.
34pub async fn dispatch_to_service(
35    service: &'static str,
36    registry: Arc<ServiceRegistry>,
37    config: Arc<DispatchConfig>,
38    mut request: Request<Body>,
39) -> Response<Body> {
40    let remote_addr = request
41        .extensions()
42        .get::<ConnectInfo<SocketAddr>>()
43        .map(|c| c.0)
44        .unwrap_or_else(|| SocketAddr::from(([127, 0, 0, 1], 0)));
45    let query = match Query::<HashMap<String, String>>::try_from_uri(request.uri()) {
46        Ok(q) => q,
47        Err(e) => {
48            return build_error_response(
49                StatusCode::BAD_REQUEST,
50                "InvalidArgument",
51                &format!("Invalid query string: {e}"),
52                &uuid::Uuid::new_v4().to_string(),
53                AwsProtocol::Rest,
54            )
55        }
56    };
57    request.extensions_mut().insert(PinnedService(service));
58    dispatch(
59        ConnectInfo(remote_addr),
60        Extension(registry),
61        Extension(config),
62        query,
63        request,
64    )
65    .await
66}
67
68/// Services whose Smithy model marks operations `@requestCompression`, so a
69/// client may send a gzip `Content-Encoding` body that the service decodes.
70/// Elsewhere `Content-Encoding` is left alone: S3 stores it as object metadata
71/// and API Gateway forwards it to the backend.
72const REQUEST_COMPRESSION_SERVICES: &[&str] = &["monitoring"];
73
74/// Decompressed bodies are capped like buffered ones, so a small gzip bomb
75/// can't expand past the request size limit.
76fn decode_request_compression(
77    headers: &http::HeaderMap,
78    rpc_v2_cbor: Option<&protocol::DetectedRequest>,
79    body: Bytes,
80) -> Result<Bytes, (String, AwsProtocol)> {
81    let gzipped = headers
82        .get_all(http::header::CONTENT_ENCODING)
83        .iter()
84        .filter_map(|v| v.to_str().ok())
85        .flat_map(|v| v.split(','))
86        .any(|enc| enc.trim().eq_ignore_ascii_case("gzip"));
87    if !gzipped || body.is_empty() {
88        return Ok(body);
89    }
90    let target = headers
91        .get("x-amz-target")
92        .and_then(|v| v.to_str().ok())
93        .and_then(protocol::parse_amz_target);
94    let (service, protocol) = if let Some(d) = rpc_v2_cbor {
95        (Some(d.service.clone()), AwsProtocol::RpcV2Cbor)
96    } else if let Some(d) = target {
97        (Some(d.service), AwsProtocol::Json)
98    } else {
99        (
100            protocol::extract_service_from_auth(headers),
101            AwsProtocol::Query,
102        )
103    };
104    if !service.is_some_and(|s| REQUEST_COMPRESSION_SERVICES.contains(&s.as_str())) {
105        return Ok(body);
106    }
107    use std::io::Read;
108    let limit = max_request_body_bytes() as u64;
109    let mut out = Vec::new();
110    let read = flate2::read::MultiGzDecoder::new(body.as_ref())
111        .take(limit + 1)
112        .read_to_end(&mut out);
113    match read {
114        Ok(_) if out.len() as u64 > limit => {
115            Err(("Decompressed request body too large".to_string(), protocol))
116        }
117        Ok(_) => Ok(Bytes::from(out)),
118        Err(e) => Err((
119            format!("Unable to decompress gzip request body: {e}"),
120            protocol,
121        )),
122    }
123}
124
125/// awsJson content type of protocol version 1.0.
126const AWS_JSON_1_0: &str = "application/x-amz-json-1.0";
127/// awsJson content type of protocol version 1.1, the services' default.
128const AWS_JSON_1_1: &str = "application/x-amz-json-1.1";
129
130/// The main dispatch handler. All HTTP requests come through here.
131pub async fn dispatch(
132    ConnectInfo(remote_addr): ConnectInfo<SocketAddr>,
133    Extension(registry): Extension<Arc<ServiceRegistry>>,
134    Extension(config): Extension<Arc<DispatchConfig>>,
135    Query(query_params): Query<HashMap<String, String>>,
136    request: Request<Body>,
137) -> Response<Body> {
138    let json_1_0 = request
139        .headers()
140        .get(http::header::CONTENT_TYPE)
141        .and_then(|v| v.to_str().ok())
142        .is_some_and(|ct| ct.trim().eq_ignore_ascii_case(AWS_JSON_1_0));
143    let mut response = dispatch_inner(remote_addr, registry, config, query_params, request).await;
144    if json_1_0 {
145        answer_in_json_1_0(&mut response);
146    }
147    response
148}
149
150/// An awsJson 1.0 service (DynamoDB, SQS, CloudWatch, Step Functions, ...)
151/// answers in the content type of its protocol version, which its clients
152/// send on the request. Handlers render JSON as 1.1, so relabel the response
153/// (success or error) for a 1.0 caller.
154fn answer_in_json_1_0(response: &mut Response<Body>) {
155    let is_1_1 = response
156        .headers()
157        .get(http::header::CONTENT_TYPE)
158        .and_then(|v| v.to_str().ok())
159        .is_some_and(|ct| ct.eq_ignore_ascii_case(AWS_JSON_1_1));
160    if is_1_1 {
161        response.headers_mut().insert(
162            http::header::CONTENT_TYPE,
163            http::HeaderValue::from_static(AWS_JSON_1_0),
164        );
165    }
166}
167
168async fn dispatch_inner(
169    remote_addr: SocketAddr,
170    registry: Arc<ServiceRegistry>,
171    config: Arc<DispatchConfig>,
172    query_params: HashMap<String, String>,
173    request: Request<Body>,
174) -> Response<Body> {
175    let remote_addr = Some(remote_addr);
176    let request_id = uuid::Uuid::new_v4().to_string();
177
178    let (mut parts, body) = request.into_parts();
179    // Set only by [`dispatch_to_service`]: the request is for this service,
180    // whatever its headers or query say.
181    let pinned = parts.extensions.get::<PinnedService>().map(|p| p.0);
182
183    // Streaming opt-in: if the route is a known large-body S3 / ECR
184    // upload, we skip the buffered `to_bytes` step entirely and hand
185    // the raw body to the service handler. The handler spills it to
186    // disk on the fly. Header-only detection covers every streaming
187    // candidate (none of them rely on form-body sniffing).
188    // Smithy RPC v2 CBOR is recognized by its `smithy-protocol` header and
189    // `/service/{Service}/operation/{Op}` path before any other detection:
190    // its SigV4 scope alone would otherwise route it as the service's Query
191    // protocol.
192    let rpc_v2_cbor = if pinned.is_none() {
193        protocol::detect_rpc_v2_cbor(&parts.headers, parts.uri.path())
194    } else {
195        None
196    };
197    let stream_route = streaming_route(
198        &parts.method,
199        parts.uri.path(),
200        &parts.headers,
201        &query_params,
202    );
203    // An unsigned plain-host request addressed to an existing bucket, or one
204    // on an API Gateway custom domain, is S3's or the API's even when its
205    // query carries an `Action` (protocol detection handles every case it
206    // can tell from the request alone): detect its service without it.
207    let action_detection_params;
208    let detect_params = if pinned.is_none()
209        && query_params.contains_key("Action")
210        && unsigned_action_is_a_parameter(
211            &parts.method,
212            &parts.uri,
213            &parts.headers,
214            &query_params,
215            &registry,
216            &config,
217        ) {
218        let mut params = query_params.clone();
219        params.remove("Action");
220        action_detection_params = params;
221        &action_detection_params
222    } else {
223        &query_params
224    };
225    let header_only = protocol::detect_service_headers_only(&parts.headers, detect_params);
226    let early_region = request_region(&parts.headers, &query_params, &config.region);
227    let early = EarlyRequest {
228        method: &parts.method,
229        wire_path: parts.uri.path(),
230        headers: &parts.headers,
231        query_params: &query_params,
232        anonymous: presents_no_credential(&parts.headers, &query_params)
233            && parts.extensions.get::<InternalCaller>().is_none(),
234        region: &early_region,
235        request_id: &request_id,
236    };
237    // A request its headers route to a service is first judged on its head
238    // alone, so one refused for its URL (S3's conflicting resource-type
239    // parameters, say) is not read in full -- up to the request-size cap --
240    // just to be refused. Only when the headers settle the service: a
241    // request whose detection rests on a presigned URL or the Host header
242    // alone may name another service in a form body, and goes there.
243    //
244    // A request nothing in its head routes (no signature, target header or
245    // service host) goes by the unrouted-request rule ([`unrouted_target`]),
246    // which reads no body: settle it now -- an unsigned path-style S3 request
247    // is then judged and streamed like any other S3 request -- unless a form
248    // body could still name a Query service.
249    let unrouted = (pinned.is_none()
250        && rpc_v2_cbor.is_none()
251        && header_only.is_none()
252        && !parts.headers.contains_key("x-amz-target")
253        && !protocol::is_form_content_type(&parts.headers))
254    .then(|| {
255        unrouted_target(
256            &parts.method,
257            &parts.uri,
258            &parts.headers,
259            &query_params,
260            &registry,
261            &config,
262        )
263    })
264    .flatten();
265    let unrouted_detected = unrouted.map(unrouted_detection);
266    let head_detected = header_only
267        .as_ref()
268        .filter(|_| {
269            pinned.is_none()
270                && rpc_v2_cbor.is_none()
271                && protocol::headers_name_final_service(&parts.headers, &query_params)
272        })
273        .or(unrouted_detected.as_ref());
274    // SigV2 query authentication S3 cannot accept is refused ahead of
275    // everything S3 judges on the head (an anonymous caller's NoSuchBucket
276    // included), as S3 does. Checked again once detection settles.
277    if pinned.is_none()
278        && head_detected
279            .or(header_only.as_ref())
280            .is_some_and(|d| d.service == "s3")
281    {
282        if let Some((message, fields)) =
283            s3_sigv2_query_auth_error(&parts.headers, &query_params, chrono::Utc::now())
284        {
285            return build_error_response_with_fields(
286                StatusCode::FORBIDDEN,
287                "AccessDenied",
288                &message,
289                &request_id,
290                AwsProtocol::Rest,
291                &fields,
292            );
293        }
294    }
295    if let Some(detected) = head_detected {
296        if let Some(service) = registry.get(&detected.service) {
297            if let Err(err) = with_request_head(service.as_ref(), &early, |head| {
298                service.refuse_before_body(head)
299            }) {
300                return service_error_response(
301                    &err,
302                    detected,
303                    &request_id,
304                    ErrorEnvelope::for_request(detected, &parts.headers),
305                );
306            }
307        }
308    }
309    let stream_dispatch = match (&stream_route, &header_only) {
310        // A pinned request is always buffered: its caller already holds the
311        // whole body, and header detection must not pick its service.
312        _ if pinned.is_some() => None,
313        _ if rpc_v2_cbor.is_some() => None,
314        // Header-only detection agrees with the URL match (an S3 PUT, SigV4
315        // service=s3 in Authorization), and the service streams this request.
316        (Some(sr), Some(detected))
317            if sr.0 == detected.service
318                && registry
319                    .get(&detected.service)
320                    .is_some_and(|service| service_streams_body(service.as_ref(), &early)) =>
321        {
322            Some(detected.clone())
323        }
324        // ECR OCI v2 blob upload has no AWS auth header; the path
325        // alone (`/v2/.../blobs/uploads/...`) tells us the route is
326        // ECR. Synthesize a DetectedRequest so dispatch picks the
327        // streaming path. Same special-case the buffered branch
328        // applies on detect_service None (see below).
329        (Some((service, _)), None) if *service == "ecr" => Some(protocol::DetectedRequest {
330            service: "ecr".to_string(),
331            action: String::new(),
332            protocol: AwsProtocol::Rest,
333        }),
334        // An unsigned request S3 serves -- path-style on the shared listener
335        // (settled above) or addressed to an S3 endpoint host -- streams when
336        // S3 says so, like a signed one.
337        (None, header_only)
338            if early.anonymous
339                && match header_only {
340                    Some(detected) => detected.service == "s3",
341                    None => unrouted == Some("s3"),
342                }
343                && registry
344                    .get("s3")
345                    .is_some_and(|s3| service_streams_body(s3.as_ref(), &early)) =>
346        {
347            Some(unrouted_detection("s3"))
348        }
349        _ => None,
350    };
351
352    let (body_bytes, body_stream) = if stream_dispatch.is_some() {
353        (Bytes::new(), Some(body))
354    } else {
355        // Buffered path: materialize the body into memory under the
356        // configured cap. `FAKECLOUD_MAX_REQUEST_BODY_BYTES` (default
357        // 1 GiB) caps non-streaming requests; streaming routes have no
358        // cap because nothing materializes the entire body in RAM.
359        let max_body_bytes = max_request_body_bytes();
360        match axum::body::to_bytes(body, max_body_bytes).await {
361            Ok(b) => (b, None),
362            Err(_) => {
363                return build_error_response(
364                    StatusCode::PAYLOAD_TOO_LARGE,
365                    "RequestEntityTooLarge",
366                    "Request body too large",
367                    &request_id,
368                    AwsProtocol::Query,
369                );
370            }
371        }
372    };
373
374    // `@requestCompression`: a client may gzip the body of an operation whose
375    // model allows it. Decode it before anything parses the body (Query
376    // detection reads the form body), but keep the wire bytes for SigV4,
377    // which signed the compressed payload.
378    let wire_body = body_bytes.clone();
379    let body_bytes = if pinned.is_none() && stream_dispatch.is_none() {
380        match decode_request_compression(&parts.headers, rpc_v2_cbor.as_ref(), body_bytes) {
381            Ok(b) => b,
382            Err((message, protocol)) => {
383                return build_error_response(
384                    StatusCode::BAD_REQUEST,
385                    "SerializationException",
386                    &message,
387                    &request_id,
388                    protocol,
389                );
390            }
391        }
392    } else {
393        body_bytes
394    };
395
396    // Detect service and action
397    let detected = if let Some(service) = pinned {
398        protocol::DetectedRequest {
399            service: service.to_string(),
400            action: String::new(),
401            protocol: AwsProtocol::Rest,
402        }
403    } else if let Some(d) = rpc_v2_cbor {
404        d
405    } else if let Some(d) = stream_dispatch {
406        d
407    } else {
408        match protocol::detect_service(&parts.headers, detect_params, &body_bytes) {
409            Some(d) => d,
410            None => {
411                // A request carrying X-Amz-Target is unambiguously an awsJson
412                // call whose operation we couldn't map to a known service. AWS
413                // answers these with UnknownOperationException; routing them to
414                // the apigateway catch-all below would 404 with a misleading
415                // "Stage not found" instead.
416                if let Some(target) = parts
417                    .headers
418                    .get("x-amz-target")
419                    .and_then(|v| v.to_str().ok())
420                {
421                    return build_error_response(
422                        StatusCode::BAD_REQUEST,
423                        "UnknownOperationException",
424                        &format!("The operation {target} is not recognized."),
425                        &request_id,
426                        AwsProtocol::Json,
427                    );
428                }
429                match unrouted.or_else(|| {
430                    unrouted_target(
431                        &parts.method,
432                        &parts.uri,
433                        &parts.headers,
434                        &query_params,
435                        &registry,
436                        &config,
437                    )
438                }) {
439                    Some(service) => unrouted_detection(service),
440                    None => {
441                        return build_error_response(
442                            StatusCode::BAD_REQUEST,
443                            "MissingAction",
444                            "Could not determine target service or action from request",
445                            &request_id,
446                            AwsProtocol::Query,
447                        );
448                    }
449                }
450            }
451        }
452    };
453
454    // Bedrock-agent and bedrock-runtime both send `bedrock` in the SigV4
455    // credential scope, but bedrock-agent has its own service handler.
456    // Disambiguate based on the request path.
457    let detected = if detected.service == "bedrock" {
458        match bedrock_agent_service_for(&parts.method, parts.uri.path()) {
459            Some(service) => protocol::DetectedRequest {
460                service: service.to_string(),
461                ..detected
462            },
463            None => detected,
464        }
465    } else {
466        detected
467    };
468
469    // Amazon DocumentDB shares RDS's Query wire protocol, `rds` SigV4
470    // signing scope, and `rds.<region>.amazonaws.com` endpoint, so a real
471    // `aws-sdk-docdb` request is indistinguishable from `aws-sdk-rds` by
472    // signing name or host alone. The DocumentDB SDK does stamp an
473    // `api/docdb` token into its `user-agent`; use it to route the request
474    // to the dedicated `docdb` handler. (The conformance probe signs the
475    // `docdb` scope directly, so it never reaches this branch.) Requests
476    // without the token stay on `rds`.
477    let detected = if detected.service == "rds" && user_agent_indicates_docdb(&parts.headers) {
478        protocol::DetectedRequest {
479            service: "docdb".to_string(),
480            ..detected
481        }
482    } else {
483        detected
484    };
485
486    // Amazon Neptune shares RDS's Query wire protocol, `rds` SigV4 signing
487    // scope, and `rds.<region>.amazonaws.com` endpoint, so a real
488    // `aws-sdk-neptune` request is indistinguishable from `aws-sdk-rds` by
489    // signing name or host alone. The Neptune SDK does stamp an
490    // `api/neptune` token into its `user-agent`; use it to route the request
491    // to the dedicated `neptune` handler. (The conformance probe signs the
492    // `neptune` scope directly, so it never reaches this branch.) Requests
493    // without the token stay on `rds`.
494    let detected = if detected.service == "rds" && user_agent_indicates_neptune(&parts.headers) {
495        protocol::DetectedRequest {
496            service: "neptune".to_string(),
497            ..detected
498        }
499    } else {
500        detected
501    };
502
503    // SigV2 query authentication S3 cannot accept is rejected before anything
504    // reads the request (credential resolution, authorization, the bucket),
505    // as S3 does.
506    if detected.service == "s3" && pinned.is_none() {
507        if let Some((message, fields)) =
508            s3_sigv2_query_auth_error(&parts.headers, &query_params, chrono::Utc::now())
509        {
510            return build_error_response_with_fields(
511                StatusCode::FORBIDDEN,
512                "AccessDenied",
513                &message,
514                &request_id,
515                AwsProtocol::Rest,
516                &fields,
517            );
518        }
519    }
520
521    // Look up service
522    let service = match registry.get(&detected.service) {
523        Some(s) => s,
524        None => {
525            return build_error_response(
526                detected.protocol.error_status(),
527                "UnknownService",
528                &format!("Service '{}' is not available", detected.service),
529                &request_id,
530                ErrorEnvelope::for_request(&detected, &parts.headers),
531            );
532        }
533    };
534
535    // Extract region and access key from auth header (or presigned query).
536    let auth_header = parts
537        .headers
538        .get("authorization")
539        .and_then(|v| v.to_str().ok())
540        .unwrap_or("");
541    let header_info = fakecloud_aws::sigv4::parse_sigv4(auth_header);
542    let presigned_info = if header_info.is_none() {
543        // Presigned URL: credentials live in the query string.
544        fakecloud_aws::sigv4::parse_sigv4_presigned(&query_params).map(|p| p.as_info())
545    } else {
546        None
547    };
548    let sigv4_info = header_info.or(presigned_info);
549    // SigV2 presigned URLs (`AWSAccessKeyId` + `Signature` + `Expires` query
550    // parameters) carry the access key outside the SigV4 grammar, so the SigV4
551    // parsers above return None. Recover the key here so a SigV2-presigned
552    // request is attributed to its caller instead of being treated as
553    // anonymous (which would deny it under the object read auth gate).
554    // SigV2 Query authentication (`SignatureVersion=2`, in the query string
555    // or a Query call's form body) names its key the same way.
556    let form_sigv2_key = (sigv4_info.is_none()
557        && matches!(
558            detected.protocol,
559            AwsProtocol::Query | AwsProtocol::Ec2Query
560        )
561        && protocol::is_form_content_type(&parts.headers))
562    .then(|| sigv2_query_access_key(&protocol::parse_query_body(&body_bytes)))
563    .flatten();
564    let access_key_id = sigv4_info
565        .as_ref()
566        .map(|info| info.access_key.clone())
567        .or_else(|| sigv2_presigned_access_key(&query_params))
568        .or_else(|| sigv2_query_access_key(&query_params))
569        .or_else(|| form_sigv2_key.clone());
570
571    // Host-header routing hint: LocalStack-shaped
572    // `<svc>.<region>.localhost.localstack.cloud[:port]`, real-AWS
573    // `<svc>.<region>.amazonaws.com`, and every S3 virtual-hosted variant
574    // of both. Secondary region source and carries the bucket for
575    // virtual-hosted S3 path rewrite.
576    let host_info = protocol::parse_routing_host_from_headers(&parts.headers);
577
578    let region = early_region;
579
580    // Resolve the caller's principal up front so both SigV4 verification
581    // (which needs the secret) and the service handler (which needs the
582    // identity for GetCallerIdentity and IAM enforcement) share a single
583    // lookup. The root-bypass AKID skips resolution entirely — `test`
584    // credentials have no backing identity and must always pass.
585    let caller_akid = access_key_id.as_deref().unwrap_or("");
586    let resolved = if !caller_akid.is_empty() && !is_root_bypass(caller_akid) {
587        config
588            .credential_resolver
589            .as_ref()
590            .and_then(|r| r.resolve(caller_akid))
591    } else {
592        None
593    };
594    let caller_principal = resolved.as_ref().map(|r| r.principal.clone());
595    let caller_session_policies = resolved
596        .as_ref()
597        .map(|r| r.session_policies.clone())
598        .unwrap_or_default();
599
600    // Opt-in SigV4 cryptographic verification. Runs before the service
601    // handler so a failing signature never reaches business logic. The
602    // reserved `test*` root identity short-circuits verification to keep
603    // local-dev workflows frictionless.
604    //
605    // A fully anonymous request — no `Authorization` header AND no presigned
606    // credential (SigV4 `X-Amz-Credential` or SigV2 `AWSAccessKeyId`) — carries
607    // no signature to verify, so it must NOT be hard-403'd here even under
608    // `--verify-sigv4`. AWS treats an unsigned request as the anonymous
609    // principal and lets a resource policy / public-read ACL authorize it
610    // (e.g. a public S3 object GET). Let it fall through to the
611    // anonymous-authorization path below, which allows a public resource and
612    // otherwise denies. A request that DID present a credential but failed to
613    // parse is NOT anonymous (`Authorization` non-empty or a presign query
614    // present) and still returns the malformed-signature error below.
615    let is_fully_anonymous =
616        presents_no_credential(&parts.headers, &query_params) && form_sigv2_key.is_none();
617    if config.verify_sigv4
618        && !is_fully_anonymous
619        && !is_root_bypass(caller_akid)
620        && config.credential_resolver.is_some()
621    {
622        let amz_date = parts
623            .headers
624            .get("x-amz-date")
625            .and_then(|v| v.to_str().ok());
626        let parsed = fakecloud_aws::sigv4::parse_sigv4_header(auth_header, amz_date)
627            .or_else(|| fakecloud_aws::sigv4::parse_sigv4_presigned(&query_params));
628        let parsed = match parsed {
629            Some(p) => p,
630            None => {
631                return build_error_response(
632                    StatusCode::FORBIDDEN,
633                    "IncompleteSignature",
634                    "Request is missing or has a malformed AWS Signature",
635                    &request_id,
636                    ErrorEnvelope::for_request(&detected, &parts.headers),
637                );
638            }
639        };
640        let resolved_for_verify = match resolved.as_ref() {
641            Some(r) => r,
642            None => {
643                return unresolved_credential_response(
644                    &config,
645                    caller_akid,
646                    &request_id,
647                    ErrorEnvelope::for_request(&detected, &parts.headers),
648                );
649            }
650        };
651        let headers_vec = fakecloud_aws::sigv4::headers_from_http(&parts.headers);
652        let raw_query_for_verify = parts.uri.query().unwrap_or("").to_string();
653        let verify_req = fakecloud_aws::sigv4::VerifyRequest {
654            method: parts.method.as_str(),
655            path: parts.uri.path(),
656            query: &raw_query_for_verify,
657            headers: &headers_vec,
658            body: &wire_body,
659        };
660        match fakecloud_aws::sigv4::verify(
661            &parsed,
662            &verify_req,
663            &resolved_for_verify.secret_access_key,
664            chrono::Utc::now(),
665        ) {
666            Ok(()) => {
667                // Bind the buffered request body to the signed
668                // `x-amz-content-sha256`. The sigv4 canonical builder uses that
669                // header value verbatim (it is a signed header) and deliberately
670                // does NOT re-hash the body: for streaming / aws-chunked routes
671                // `body_bytes` is empty at that layer, so re-hashing there would
672                // reject legitimate signed requests (see the
673                // `feedback_sigv4_body_hash_wrong_layer` caveat). S3 rebinds the
674                // body hash in its own write path (`XAmzContentSHA256Mismatch`);
675                // every other service is bound HERE, where the full buffered body
676                // is available. Only a genuine 64-char lowercase-hex digest is
677                // checked — `UNSIGNED-PAYLOAD`, `STREAMING-*`, and presigned
678                // (UNSIGNED-PAYLOAD) requests are skipped, so correct clients are
679                // unaffected. A mismatch means the body was altered after signing;
680                // AWS would then compute a different canonical request, so return
681                // `SignatureDoesNotMatch`.
682                if !parsed.is_presigned && detected.service != "s3" {
683                    if let Some(signed_hash) = parts
684                        .headers
685                        .get("x-amz-content-sha256")
686                        .and_then(|v| v.to_str().ok())
687                        .filter(|h| is_hex_sha256(h))
688                    {
689                        if sha256_hex_lower(&wire_body) != signed_hash {
690                            return build_error_response(
691                                StatusCode::FORBIDDEN,
692                                "SignatureDoesNotMatch",
693                                "The request signature we calculated does not match the signature you provided",
694                                &request_id,
695                                ErrorEnvelope::for_request(&detected, &parts.headers),
696                            );
697                        }
698                    }
699                }
700            }
701            Err(fakecloud_aws::sigv4::SigV4Error::RequestTimeTooSkewed { .. }) => {
702                return build_error_response(
703                    StatusCode::FORBIDDEN,
704                    "RequestTimeTooSkewed",
705                    "The difference between the request time and the current time is too large",
706                    &request_id,
707                    ErrorEnvelope::for_request(&detected, &parts.headers),
708                );
709            }
710            Err(fakecloud_aws::sigv4::SigV4Error::InvalidDate(msg)) => {
711                return build_error_response(
712                    StatusCode::FORBIDDEN,
713                    "IncompleteSignature",
714                    &format!("Invalid x-amz-date: {msg}"),
715                    &request_id,
716                    ErrorEnvelope::for_request(&detected, &parts.headers),
717                );
718            }
719            Err(fakecloud_aws::sigv4::SigV4Error::Malformed(msg)) => {
720                return build_error_response(
721                    StatusCode::FORBIDDEN,
722                    "IncompleteSignature",
723                    &format!("Malformed SigV4 signature: {msg}"),
724                    &request_id,
725                    ErrorEnvelope::for_request(&detected, &parts.headers),
726                );
727            }
728            Err(fakecloud_aws::sigv4::SigV4Error::SignatureMismatch) => {
729                return build_error_response(
730                    StatusCode::FORBIDDEN,
731                    "SignatureDoesNotMatch",
732                    "The request signature we calculated does not match the signature you provided",
733                    &request_id,
734                    ErrorEnvelope::for_request(&detected, &parts.headers),
735                );
736            }
737            Err(fakecloud_aws::sigv4::SigV4Error::PresignedUrlExpired {
738                signed,
739                server,
740                expires_secs,
741            }) => {
742                // S3's error document names the lifetime, the expiry and the
743                // server's clock, as for an expired SigV2 URL.
744                let fields = if detected.service == "s3" {
745                    vec![
746                        ("X-Amz-Expires".to_string(), expires_secs.to_string()),
747                        (
748                            "Expires".to_string(),
749                            s3_error_time(signed + chrono::Duration::seconds(expires_secs)),
750                        ),
751                        ("ServerTime".to_string(), s3_error_time(server)),
752                    ]
753                } else {
754                    Vec::new()
755                };
756                return build_error_response_with_fields(
757                    StatusCode::FORBIDDEN,
758                    "AccessDenied",
759                    "Request has expired",
760                    &request_id,
761                    ErrorEnvelope::for_request(&detected, &parts.headers),
762                    &fields,
763                );
764            }
765            Err(fakecloud_aws::sigv4::SigV4Error::InvalidPresignExpires(_)) => {
766                return build_error_response(
767                    StatusCode::BAD_REQUEST,
768                    "AuthorizationQueryParametersError",
769                    "X-Amz-Expires must be a number between 1 and 604800 seconds",
770                    &request_id,
771                    ErrorEnvelope::for_request(&detected, &parts.headers),
772                );
773            }
774        }
775    }
776
777    // A SigV4 presigned S3 URL can carry request headers as query parameters
778    // (the JS v3 presigner hoists every `x-amz-*` header this way). S3 applies
779    // them as if they were headers, so lift them into the header map now that
780    // the signature over the raw query has been verified.
781    if detected.service == "s3" && query_params.contains_key("X-Amz-Credential") {
782        hoist_presigned_query_headers(&mut parts.headers, &query_params);
783    }
784
785    // Build path segments. For S3 virtual-hosted-style requests the bucket
786    // lives in the Host header, not the path — prepend it so the S3 handler
787    // sees a uniform path-style request. SigV4 verification above already
788    // ran against the wire path, so this rewrite is signature-safe.
789    let wire_path = parts.uri.path();
790    let path = if detected.service == "s3" {
791        s3_routing_path(
792            wire_path,
793            host_info.as_ref().and_then(|h| h.bucket.as_deref()),
794        )
795    } else {
796        wire_path.to_string()
797    };
798    let raw_query = parts.uri.query().unwrap_or("").to_string();
799    // Split on `/` first, then percent-decode each segment once, so handlers
800    // see decoded `@httpLabel` values and an encoded `/` (`%2F`) stays inside
801    // its label. `raw_path` keeps the undecoded wire form.
802    let path_segments = crate::path::split_path_segments(&path);
803
804    // rpcv2Cbor: the signature above covered the CBOR wire body; from here on
805    // the request is handled on the service's JSON path, so swap in the
806    // equivalent awsJson document.
807    let body_bytes = if detected.protocol == AwsProtocol::RpcV2Cbor {
808        match crate::cbor::decode_to_json(&body_bytes) {
809            Ok(json) => Bytes::from(json.to_string()),
810            Err(e) => {
811                return build_error_response(
812                    StatusCode::BAD_REQUEST,
813                    "SerializationException",
814                    &format!("Unable to decode CBOR request body: {e}"),
815                    &request_id,
816                    AwsProtocol::RpcV2Cbor,
817                );
818            }
819        }
820    } else {
821        body_bytes
822    };
823
824    // For JSON protocol, validate that non-empty bodies are valid JSON
825    if detected.protocol == AwsProtocol::Json
826        && !body_bytes.is_empty()
827        && serde_json::from_slice::<serde_json::Value>(&body_bytes).is_err()
828    {
829        return build_error_response(
830            StatusCode::BAD_REQUEST,
831            "SerializationException",
832            "Start of structure or map found where not expected",
833            &request_id,
834            AwsProtocol::Json,
835        );
836    }
837
838    // Merge query params with form body params for the Query family (awsQuery
839    // and ec2Query share identical form-encoded request bodies).
840    let mut all_params = query_params;
841    if matches!(
842        detected.protocol,
843        AwsProtocol::Query | AwsProtocol::Ec2Query
844    ) {
845        let body_params = protocol::parse_query_body(&body_bytes);
846        for (k, v) in body_params {
847            all_params.entry(k).or_insert(v);
848        }
849    }
850
851    // CloudWatch (`monitoring`) advertises awsJson1_0 alongside awsQuery. Its
852    // handlers all read the flat awsQuery param map, so when a client uses the
853    // JSON protocol we flatten the JSON body into that same map, leaving the
854    // handlers unchanged. The handler emits a JSON response for JSON callers.
855    if matches!(
856        detected.protocol,
857        AwsProtocol::Json | AwsProtocol::RpcV2Cbor
858    ) && detected.service == "monitoring"
859    {
860        let body_params = protocol::flatten_json_to_query(&body_bytes);
861        for (k, v) in body_params {
862            all_params.entry(k).or_insert(v);
863        }
864    }
865
866    // An in-process request fakecloud issues as an AWS-owned principal (e.g.
867    // CloudFront fetching an S3 origin through an origin access control). The
868    // identity rides a request extension that only in-process code can set,
869    // and is honored only when the request presents no credentials of its
870    // own: a request that does is authorized as that caller instead.
871    let internal_caller = if is_fully_anonymous && access_key_id.is_none() {
872        parts.extensions.get::<InternalCaller>().cloned()
873    } else {
874        None
875    };
876    let caller_principal =
877        caller_principal.or_else(|| internal_caller.as_ref().map(|c| c.principal()));
878
879    let error_envelope = ErrorEnvelope::for_request(&detected, &parts.headers);
880    let mut aws_request = AwsRequest {
881        service: detected.service.clone(),
882        action: detected.action.clone(),
883        region,
884        // A service acting for a customer works on the target resource in its
885        // owner's account: an S3 origin bucket may belong to another account
886        // than the distribution fetching it.
887        account_id: match internal_caller.as_ref() {
888            Some(caller) => {
889                internal_caller_account(caller, &detected.service, &path_segments, &config)
890            }
891            None => caller_principal
892                .as_ref()
893                .map(|p| p.account_id.clone())
894                .unwrap_or_else(|| config.account_id.clone()),
895        },
896        request_id: request_id.clone(),
897        headers: parts.headers,
898        query_params: all_params,
899        body: body_bytes,
900        body_stream: parking_lot::Mutex::new(body_stream),
901        path_segments,
902        raw_path: path,
903        raw_query,
904        method: parts.method,
905        is_query_protocol: matches!(
906            detected.protocol,
907            AwsProtocol::Query | AwsProtocol::Ec2Query
908        ),
909        access_key_id,
910        principal: caller_principal,
911    };
912
913    tracing::info!(
914        service = %aws_request.service,
915        action = %aws_request.action,
916        request_id = %aws_request.request_id,
917        "handling request"
918    );
919
920    // One form of the request for everything below: pre-authorization
921    // validation, IAM derivation and the handler read the same body.
922    if let Err(err) = service.prepare_request(&mut aws_request) {
923        return service_error_response(&err, &detected, &request_id, error_envelope);
924    }
925
926    let enforcement = IamEnforcement {
927        config: config.clone(),
928        service: service.clone(),
929        detected: detected.clone(),
930        request_id: request_id.clone(),
931        remote_addr,
932        resolved,
933        caller_session_policies,
934        internal_caller,
935    };
936    // One order for every request, whatever the enforcement mode:
937    //
938    // 0. an unsigned request to an operation that needs credentials is
939    //    refused for the missing credentials (strict only, see below);
940    // 1. authentication: a key that resolves to no identity is refused
941    //    first (`InvalidClientTokenId` / `ExpiredToken`, strict only);
942    // 2. `validate_before_authorization`: what the service refuses whoever
943    //    asks (S3's unclassifiable requests and a missing bucket, STS's
944    //    invalid `TaskPolicyArn`). An anonymous request is validated here
945    //    only by a service that judges anonymous requests itself
946    //    (`validates_anonymous_before_authorization`, S3); every other
947    //    service refuses it as anonymous first and validates after step 3;
948    // 3. authorization of the request's actions;
949    // 4. the handler, which refuses the payload itself (S3's broken
950    //    aws-chunked framing) only after all of the above.
951    //
952    // With enforcement off (or for an operation that needs no
953    // authorization, `sts:GetCallerIdentity`, from a caller whose
954    // credentials resolved) steps 1 and 3 do not apply and validation runs
955    // just before the handler.
956    let anonymous = aws_request.access_key_id.is_none()
957        && aws_request.principal.is_none()
958        && enforcement.internal_caller.is_none();
959    // 0. An unsigned API request is refused for its missing credentials
960    //    ahead of everything else, unless the service authorizes anonymous
961    //    callers (S3, SQS) or the operation needs no credentials. Every
962    //    service, enforced or not: this is authentication, not policy.
963    let anonymous_access = (anonymous
964        && is_fully_anonymous
965        && config.iam_mode.is_enabled()
966        && names_api_operation(
967            &detected,
968            host_info.as_ref(),
969            &aws_request.method,
970            &aws_request.headers,
971            || service.is_control_plane_request(&aws_request),
972        ))
973    .then(|| service.anonymous_access(&aws_request));
974    if anonymous_access == Some(crate::anonymous::AnonymousAccess::Refused) {
975        tracing::warn!(
976            target: "fakecloud::iam::audit",
977            service = %detected.service,
978            action = %aws_request.action,
979            mode = %config.iam_mode,
980            request_id = %request_id,
981            "unsigned request is missing authentication; denying under strict, allowing under soft"
982        );
983        if config.iam_mode.is_strict() {
984            return missing_authentication_response(&detected, &request_id, &aws_request.headers);
985        }
986    }
987    let unauthenticated_operation =
988        anonymous_access == Some(crate::anonymous::AnonymousAccess::Unauthenticated);
989    let authorization_required =
990        !unauthenticated_operation && service.iam_authorization_required(&aws_request);
991    let authorizes = enforcement.is_active(&aws_request) && authorization_required;
992    if authorizes {
993        if let Some(denied) = enforcement.unauthenticated(&aws_request) {
994            return denied;
995        }
996    }
997    let refuse_anonymous_first =
998        authorizes && anonymous && !service.validates_anonymous_before_authorization();
999    if !refuse_anonymous_first {
1000        if let Err(err) = service.validate_before_authorization(&aws_request) {
1001            return service_error_response(&err, &detected, &request_id, error_envelope);
1002        }
1003    }
1004    if authorizes {
1005        // A request whose handler authorizes it per item (S3 DeleteObjects,
1006        // per key) needs no authorization of its own here; the handler asks
1007        // the request's authorizer instead.
1008        let deferred = service.iam_authorized_by_handler(&aws_request);
1009        let iam_actions = if deferred {
1010            Vec::new()
1011        } else {
1012            service.iam_actions_for(&aws_request)
1013        };
1014        if let Some(denied) = enforcement.enforce(&aws_request, &iam_actions, deferred) {
1015            return denied;
1016        }
1017    }
1018    // An anonymous request authorization let through (a resource made
1019    // public, or soft mode) is validated now, before its handler.
1020    if refuse_anonymous_first {
1021        if let Err(err) = service.validate_before_authorization(&aws_request) {
1022            return service_error_response(&err, &detected, &request_id, error_envelope);
1023        }
1024    }
1025    // Handlers that authorize further actions (per item, or to decide what a
1026    // response may reveal) evaluate them exactly as above.
1027    let authorizer = enforcement.is_active(&aws_request).then(|| {
1028        Arc::new(DispatchAuthorizer {
1029            request: authorization_copy(&aws_request),
1030            enforcement,
1031        }) as Arc<dyn crate::auth::RequestAuthorizer>
1032    });
1033    let service = service.clone();
1034    finish_dispatch(
1035        service,
1036        aws_request,
1037        authorizer,
1038        detected,
1039        request_id,
1040        error_envelope,
1041    )
1042    .await
1043}
1044
1045/// Everything dispatch needs to authorize IAM actions for one request: the
1046/// request's own actions before its handler runs, and any further ones the
1047/// handler asks for through [`DispatchAuthorizer`].
1048#[derive(Clone)]
1049struct IamEnforcement {
1050    config: Arc<DispatchConfig>,
1051    service: Arc<dyn crate::service::AwsService>,
1052    detected: protocol::DetectedRequest,
1053    request_id: String,
1054    remote_addr: Option<SocketAddr>,
1055    resolved: Option<crate::auth::ResolvedCredential>,
1056    caller_session_policies: Vec<String>,
1057    internal_caller: Option<InternalCaller>,
1058}
1059
1060/// A request's [`crate::auth::RequestAuthorizer`]: evaluates an action for
1061/// the request's caller as dispatch evaluates the request's own (identity
1062/// policies, boundary, session policies, SCPs, resource policy, ACLs; the
1063/// root, service-principal and anonymous paths alike), in the same mode.
1064struct DispatchAuthorizer {
1065    enforcement: IamEnforcement,
1066    request: AwsRequest,
1067}
1068
1069impl crate::auth::RequestAuthorizer for DispatchAuthorizer {
1070    fn service_name(&self) -> &str {
1071        &self.enforcement.detected.service
1072    }
1073
1074    fn authorize(&self, action: &crate::auth::IamAction) -> bool {
1075        self.enforcement
1076            .enforce(&self.request, std::slice::from_ref(action), false)
1077            .is_none()
1078    }
1079
1080    fn authorize_for(&self, request: &AwsRequest, action: &crate::auth::IamAction) -> bool {
1081        // `request` as if signed with its key: that key's resolved credential
1082        // and principal, which every enforcement path reads.
1083        let resolved = request
1084            .access_key_id
1085            .as_deref()
1086            .filter(|key| !is_root_bypass(key))
1087            .and_then(|key| {
1088                self.enforcement
1089                    .config
1090                    .credential_resolver
1091                    .as_ref()
1092                    .and_then(|r| r.resolve(key))
1093            });
1094        let mut request = authorization_copy(request);
1095        request.principal = resolved.as_ref().map(|r| r.principal.clone());
1096        let enforcement = IamEnforcement {
1097            caller_session_policies: resolved
1098                .as_ref()
1099                .map(|r| r.session_policies.clone())
1100                .unwrap_or_default(),
1101            resolved,
1102            internal_caller: None,
1103            ..self.enforcement.clone()
1104        };
1105        enforcement
1106            .enforce(&request, std::slice::from_ref(action), false)
1107            .is_none()
1108    }
1109}
1110
1111/// A copy of `request` for authorizing further actions after the request
1112/// itself has gone to its handler. Its body stream, which only the handler
1113/// reads, is left out.
1114fn authorization_copy(request: &AwsRequest) -> AwsRequest {
1115    AwsRequest {
1116        service: request.service.clone(),
1117        action: request.action.clone(),
1118        region: request.region.clone(),
1119        account_id: request.account_id.clone(),
1120        request_id: request.request_id.clone(),
1121        headers: request.headers.clone(),
1122        query_params: request.query_params.clone(),
1123        body: request.body.clone(),
1124        body_stream: parking_lot::Mutex::new(None),
1125        path_segments: request.path_segments.clone(),
1126        raw_path: request.raw_path.clone(),
1127        raw_query: request.raw_query.clone(),
1128        method: request.method.clone(),
1129        is_query_protocol: request.is_query_protocol,
1130        access_key_id: request.access_key_id.clone(),
1131        principal: request.principal.clone(),
1132    }
1133}
1134
1135impl IamEnforcement {
1136    /// Whether IAM enforcement applies to `aws_request` at all: on, for a
1137    /// service that opted in, for a caller other than the `test*` root
1138    /// bypass, with an evaluator configured.
1139    fn is_active(&self, aws_request: &AwsRequest) -> bool {
1140        self.config.iam_mode.is_enabled()
1141            && self.config.policy_evaluator.is_some()
1142            && self.service.iam_enforceable()
1143            && !is_root_bypass(aws_request.access_key_id.as_deref().unwrap_or(""))
1144    }
1145
1146    /// The refusal of a request whose access key resolves to no identity,
1147    /// under strict enforcement: AWS authenticates before anything else, so
1148    /// this comes ahead of validation and authorization. `None` for any other
1149    /// request, and under soft mode (where [`IamEnforcement::enforce`] logs
1150    /// it and lets it through).
1151    fn unauthenticated(&self, aws_request: &AwsRequest) -> Option<Response<Body>> {
1152        if !self.config.iam_mode.is_strict()
1153            || self.internal_caller.is_some()
1154            || aws_request.principal.is_some()
1155        {
1156            return None;
1157        }
1158        let akid = aws_request.access_key_id.as_deref()?;
1159        Some(self.unresolved_credential(aws_request, akid))
1160    }
1161
1162    /// Log and refuse a request whose key `akid` resolves to no identity.
1163    fn unresolved_credential(&self, aws_request: &AwsRequest, akid: &str) -> Response<Body> {
1164        tracing::warn!(
1165            target: "fakecloud::iam::audit",
1166            service = %self.detected.service,
1167            action = %aws_request.action,
1168            mode = %self.config.iam_mode,
1169            request_id = %self.request_id,
1170            "request credential does not resolve to an identity; denying under strict, allowing under soft"
1171        );
1172        unresolved_credential_response(
1173            &self.config,
1174            akid,
1175            &self.request_id,
1176            ErrorEnvelope::for_request(&self.detected, &aws_request.headers),
1177        )
1178    }
1179
1180    /// Authorize `iam_actions` for `aws_request`. Returns the error response
1181    /// to send when strict enforcement refuses the request; soft enforcement
1182    /// only logs, and returns `None`. An empty list is an unmapped operation,
1183    /// which strict refuses, unless `deferred` (the handler authorizes it).
1184    fn enforce(
1185        &self,
1186        aws_request: &AwsRequest,
1187        iam_actions: &[crate::auth::IamAction],
1188        deferred: bool,
1189    ) -> Option<Response<Body>> {
1190        let Self {
1191            config,
1192            service,
1193            detected,
1194            request_id,
1195            resolved,
1196            caller_session_policies,
1197            internal_caller,
1198            ..
1199        } = self;
1200        let remote_addr = self.remote_addr;
1201        let error_envelope = ErrorEnvelope::for_request(detected, &aws_request.headers);
1202        // Opt-in IAM identity-policy enforcement. Runs before the service
1203        // handler so a deny never reaches business logic. `test*` bypass AKIDs
1204        // are exempt; the account's IAM root needs no identity policy, as in
1205        // AWS, but stays capped by its SCPs and, across accounts, by the
1206        // resource's policy (`authorize_root`). Services that haven't opted in via
1207        // `iam_enforceable()` are transparently skipped — the startup log
1208        // lists which services are under enforcement so users always know.
1209        if self.is_active(aws_request) {
1210            if let Some(evaluator) = config.policy_evaluator.as_ref() {
1211                if let Some(caller) = internal_caller.as_ref() {
1212                    if let Some(denied) = authorize_internal_caller(
1213                        caller,
1214                        service.as_ref(),
1215                        aws_request,
1216                        evaluator.as_ref(),
1217                        config,
1218                        detected,
1219                        request_id,
1220                        iam_actions,
1221                        deferred,
1222                    ) {
1223                        return Some(denied);
1224                    }
1225                } else if let Some(principal) = aws_request.principal.as_ref() {
1226                    if principal.is_root() {
1227                        if let Some(denied) = authorize_root(
1228                            principal,
1229                            service.as_ref(),
1230                            aws_request,
1231                            evaluator.as_ref(),
1232                            config,
1233                            detected,
1234                            request_id,
1235                            remote_addr,
1236                            resolved.as_ref(),
1237                            iam_actions,
1238                            deferred,
1239                        ) {
1240                            return Some(denied);
1241                        }
1242                    } else {
1243                        // A request can need several authorizations -- one per
1244                        // table in a batch, say -- and every one must allow it.
1245                        if deferred || !iam_actions.is_empty() {
1246                            for iam_action in iam_actions {
1247                                let mut condition_context = principal_condition_context(
1248                                    principal,
1249                                    resolved.as_ref(),
1250                                    service.as_ref(),
1251                                    aws_request,
1252                                    iam_action,
1253                                    detected,
1254                                    remote_addr,
1255                                );
1256                                let service_resource = !iam_action.is_pass_role();
1257
1258                                // Phase 2: fetch the resource-based policy (if
1259                                // any) attached to the target resource and
1260                                // pass it to the evaluator alongside the
1261                                // principal's identity policies. The resource's
1262                                // owning account is parsed from the ARN (#381
1263                                // multi-account alignment); S3 ARNs have an
1264                                // empty account field, so we fall back to the
1265                                // server's configured account ID in that case.
1266                                // A request creating its resource in the caller's
1267                                // account (S3 CreateBucket) is same-account, whatever
1268                                // account holds that name today.
1269                                let in_caller_account =
1270                                    service.iam_resource_in_caller_account(aws_request);
1271                                let resource_policy_json = config
1272                                    .resource_policy_provider
1273                                    .as_ref()
1274                                    .filter(|_| service_resource && !in_caller_account)
1275                                    .filter(|p| {
1276                                        p.resource_policy_applies(&detected.service, iam_action)
1277                                    })
1278                                    .and_then(|p| {
1279                                        p.resource_policy(&detected.service, &iam_action.resource)
1280                                    });
1281                                // Derive the resource-owning account. Prefer a provider
1282                                // lookup (S3 ARNs carry no account, so the bucket's
1283                                // owner is resolved from state — without this, account
1284                                // A reaching account B's bucket would be mis-read as
1285                                // same-account and skip B's bucket-policy requirement,
1286                                // bug-audit 2026-05-28, 5.3), then fall back to the
1287                                // account embedded in the ARN (SQS/SNS/Lambda/…), then
1288                                // to the caller's account for wildcard / unscoped
1289                                // actions (ListQueues, GetCallerIdentity).
1290                                let resource_account_id = config
1291                                    .resource_policy_provider
1292                                    .as_ref()
1293                                    .filter(|_| service_resource && !in_caller_account)
1294                                    .and_then(|p| {
1295                                        p.resource_owner_account(
1296                                            &detected.service,
1297                                            &iam_action.resource,
1298                                        )
1299                                    })
1300                                    .or_else(|| parse_account_from_arn(&iam_action.resource))
1301                                    .unwrap_or_else(|| principal.account_id.clone());
1302                                // SCP ceiling: resolve the inherited SCP chain
1303                                // for this principal (management accounts and
1304                                // service-linked roles come back as `None`, in
1305                                // which case the evaluator treats the layer as
1306                                // absent). Audit breadcrumbs emitted by the
1307                                // resolver itself, not here.
1308                                let scps = config
1309                                    .scp_resolver
1310                                    .as_ref()
1311                                    .and_then(|r| r.scps_for(principal));
1312                                // Global keys AWS sets on every request, which
1313                                // perimeter guardrails gate on (`Deny ...
1314                                // StringNotEquals aws:PrincipalOrgID` /
1315                                // `aws:ResourceAccount`): absent, those negated
1316                                // conditions would deny everyone.
1317                                add_global_request_keys(
1318                                    &mut condition_context,
1319                                    principal,
1320                                    &resource_account_id,
1321                                    config.scp_resolver.as_deref(),
1322                                );
1323                                let decision = evaluator.evaluate_with_resource_policy(
1324                                    principal,
1325                                    iam_action,
1326                                    &condition_context,
1327                                    resource_policy_json.as_deref(),
1328                                    &resource_account_id,
1329                                    caller_session_policies,
1330                                    scps.as_deref(),
1331                                );
1332                                // A grant on the resource (KMS) only adds
1333                                // permission: it is consulted when the policies
1334                                // left the request undecided, never over an
1335                                // explicit Deny.
1336                                let decision = if decision == crate::auth::IamDecision::ImplicitDeny
1337                                    && service_resource
1338                                    && !in_caller_account
1339                                    && grant_authorizes(
1340                                        config.resource_policy_provider.as_deref(),
1341                                        evaluator.as_ref(),
1342                                        &detected.service,
1343                                        aws_request,
1344                                        iam_action,
1345                                        principal,
1346                                        &condition_context,
1347                                        caller_session_policies,
1348                                        scps.as_deref(),
1349                                    ) {
1350                                    crate::auth::IamDecision::Allow
1351                                } else {
1352                                    decision
1353                                };
1354                                if !decision.is_allow() {
1355                                    tracing::warn!(
1356                                        target: "fakecloud::iam::audit",
1357                                        service = %detected.service,
1358                                        action = %iam_action.action_string(),
1359                                        resource = %iam_action.resource,
1360                                        principal = %principal.arn,
1361                                        resource_policy_present = resource_policy_json.is_some(),
1362                                        decision = ?decision,
1363                                        mode = %config.iam_mode,
1364                                        request_id = %request_id,
1365                                        "IAM policy evaluation denied request"
1366                                    );
1367                                    if config.iam_mode.is_strict() {
1368                                        // Real AWS includes an "Encoded
1369                                        // authorization failure message" suffix
1370                                        // on AccessDeniedException — an opaque
1371                                        // base64+zlib JSON blob that the caller
1372                                        // can pass to STS
1373                                        // `DecodeAuthorizationMessage` to
1374                                        // recover the structured deny reason
1375                                        // (action, principal, matched
1376                                        // statements, condition context). We
1377                                        // produce the same blob inline so
1378                                        // existing tooling that decodes deny
1379                                        // reasons works against fakecloud.
1380                                        let context_summary = serde_json::json!({
1381                                            "aws:PrincipalArn": condition_context
1382                                                .aws_principal_arn
1383                                                .clone()
1384                                                .unwrap_or_else(|| principal.arn.clone()),
1385                                            "aws:PrincipalAccount": principal.account_id,
1386                                            "aws:RequestedRegion": condition_context
1387                                                .aws_requested_region
1388                                                .clone()
1389                                                .unwrap_or_default(),
1390                                            "aws:SecureTransport": condition_context
1391                                                .aws_secure_transport
1392                                                .unwrap_or(false),
1393                                            "aws:Action": iam_action.action_string(),
1394                                            "aws:Resource": iam_action.resource,
1395                                            "decision": format!("{:?}", decision),
1396                                        });
1397                                        let action_string = iam_action.action_string();
1398                                        let encoded = crate::auth_message::encode_deny(
1399                                            matches!(
1400                                                decision,
1401                                                crate::auth::IamDecision::ExplicitDeny
1402                                            ),
1403                                            Some(&action_string),
1404                                            Some(&principal.arn),
1405                                            Vec::new(),
1406                                            Some(context_summary),
1407                                        );
1408                                        // When the SCPs are what refused the
1409                                        // request, AWS says so.
1410                                        let scp_reason = scps.as_deref().and_then(|scps| {
1411                                            let ceiling = evaluator.evaluate_ceiling(
1412                                                principal,
1413                                                iam_action,
1414                                                &condition_context,
1415                                                &[],
1416                                                Some(scps),
1417                                                false,
1418                                            );
1419                                            scp_cause(decision, ceiling)
1420                                                .and_then(|d| scp_denial_reason(d, &action_string))
1421                                        });
1422                                        return Some(access_denied_response(
1423                                            &not_authorized_message(
1424                                                &principal.arn,
1425                                                &action_string,
1426                                                &iam_action.resource,
1427                                                scp_reason.as_deref(),
1428                                                Some(&encoded),
1429                                            ),
1430                                            request_id,
1431                                            detected,
1432                                            &aws_request.headers,
1433                                        ));
1434                                    }
1435                                    // Soft mode: audit log already emitted; fall
1436                                    // through to the handler.
1437                                }
1438                            }
1439                        } else {
1440                            // Service opted in via `iam_enforceable()` but its
1441                            // `iam_action_for` returned no `IamAction` for this
1442                            // specific operation (e.g. S3's `s3_detect_action`
1443                            // has `_ => return None` arms for unrecognized
1444                            // sub-resources). Under strict enforcement that must
1445                            // fail closed: an operation we cannot map to an IAM
1446                            // action cannot be authorized, so serving it would be
1447                            // a fail-open bypass of `--iam strict`. Deny by
1448                            // default. In soft mode we preserve the historical
1449                            // warn-and-allow so an incomplete mapping surfaces
1450                            // during rollout without blocking traffic.
1451                            tracing::warn!(
1452                                target: "fakecloud::iam::audit",
1453                                service = %detected.service,
1454                                action = %aws_request.action,
1455                                mode = %config.iam_mode,
1456                                request_id = %request_id,
1457                                "service is iam_enforceable but has no IamAction mapping for this action; denying under strict, allowing under soft"
1458                            );
1459                            if config.iam_mode.is_strict() {
1460                                return Some(unmapped_action_denial(
1461                                    principal,
1462                                    aws_request,
1463                                    request_id,
1464                                    detected,
1465                                ));
1466                            }
1467                            // Soft mode: audit log emitted; fall through to the
1468                            // handler.
1469                        }
1470                    }
1471                } else if let Some(akid) = aws_request
1472                    .access_key_id
1473                    .as_deref()
1474                    .filter(|_| aws_request.principal.is_none())
1475                {
1476                    // The request presented an access key that resolves to no
1477                    // identity: unknown, deactivated (Inactive), or an expired /
1478                    // revoked STS session. AWS rejects such a request before
1479                    // authorization (`InvalidClientTokenId` / `ExpiredToken`), so
1480                    // under strict enforcement it must not run unchecked -- nor
1481                    // reach STS, which would otherwise read the missing principal
1482                    // as the account root and mint real credentials. The
1483                    // explicit exemptions stay: the reserved `test*` root
1484                    // identity (excluded above) and truly anonymous requests
1485                    // (next branch). Soft mode logs and lets it through.
1486                    tracing::warn!(
1487                        target: "fakecloud::iam::audit",
1488                        service = %detected.service,
1489                        action = %aws_request.action,
1490                        mode = %config.iam_mode,
1491                        request_id = %request_id,
1492                        "request credential does not resolve to an identity; denying under strict, allowing under soft"
1493                    );
1494                    if config.iam_mode.is_strict() {
1495                        return Some(unresolved_credential_response(
1496                            config,
1497                            akid,
1498                            request_id,
1499                            error_envelope,
1500                        ));
1501                    }
1502                } else if aws_request.access_key_id.is_none() {
1503                    // Truly anonymous (unsigned) caller — no Authorization header at
1504                    // all. No identity policies exist, so authorization rests
1505                    // entirely on the resource policy (a bucket policy granting
1506                    // `Principal:"*"`) and public-read ACLs — mirroring AWS, which
1507                    // denies anonymous requests unless the resource is explicitly
1508                    // made public. Without this an anonymous request that reached an
1509                    // iam_enforceable service in enforcement mode would bypass
1510                    // authorization entirely.
1511                    //
1512                    // A request whose credential did not resolve (principal `None`
1513                    // with `access_key_id` `Some`) is handled by the branch above.
1514                    if deferred || !iam_actions.is_empty() {
1515                        for iam_action in iam_actions {
1516                            let now = chrono::Utc::now();
1517                            let mut condition_context = ConditionContext {
1518                                aws_source_ip: remote_addr.map(|sa| sa.ip()),
1519                                aws_current_time: Some(now),
1520                                aws_epoch_time: Some(now.timestamp()),
1521                                aws_secure_transport: Some(is_secure_transport(
1522                                    &aws_request.headers,
1523                                )),
1524                                aws_requested_region: Some(aws_request.region.clone()),
1525                                ..Default::default()
1526                            };
1527                            condition_context.service_keys =
1528                                service.iam_condition_keys_for(aws_request, iam_action);
1529                            // `aws:ResourceAccount` is set for anonymous requests
1530                            // too: the target resource's owner.
1531                            if let Some(owner) = config
1532                                .resource_policy_provider
1533                                .as_ref()
1534                                .and_then(|p| {
1535                                    p.resource_owner_account(
1536                                        &detected.service,
1537                                        &iam_action.resource,
1538                                    )
1539                                })
1540                                .or_else(|| parse_account_from_arn(&iam_action.resource))
1541                            {
1542                                condition_context
1543                                    .service_keys
1544                                    .entry("aws:resourceaccount".to_string())
1545                                    .or_insert_with(|| vec![owner]);
1546                            }
1547                            let resource_policy_json =
1548                                config.resource_policy_provider.as_ref().and_then(|p| {
1549                                    p.resource_policy(&detected.service, &iam_action.resource)
1550                                });
1551                            let policy_decision = evaluator.evaluate_anonymous(
1552                                iam_action,
1553                                &condition_context,
1554                                resource_policy_json.as_deref(),
1555                            );
1556                            let policy_allows = policy_decision.is_allow();
1557                            // An explicit Deny in the resource policy always wins, even
1558                            // over a public-read ACL — matching AWS's Deny-overrides
1559                            // precedence. Collapsing the decision to a bool and ORing the
1560                            // ACL let a public ACL override an explicit anonymous Deny.
1561                            let policy_explicit_deny =
1562                                matches!(policy_decision, crate::auth::IamDecision::ExplicitDeny);
1563                            let acl_allows = !policy_explicit_deny
1564                                && config.resource_policy_provider.as_ref().is_some_and(|p| {
1565                                    p.public_acl_allows(
1566                                        &detected.service,
1567                                        &iam_action.resource,
1568                                        iam_action.action,
1569                                    )
1570                                });
1571                            if !policy_allows && !acl_allows {
1572                                tracing::warn!(
1573                                    target: "fakecloud::iam::audit",
1574                                    service = %detected.service,
1575                                    action = %iam_action.action_string(),
1576                                    resource = %iam_action.resource,
1577                                    resource_policy_present = resource_policy_json.is_some(),
1578                                    mode = %config.iam_mode,
1579                                    request_id = %request_id,
1580                                    "anonymous request denied: no public bucket policy or ACL grants the action"
1581                                );
1582                                if config.iam_mode.is_strict() {
1583                                    let message = anonymous_denied_message(
1584                                        &detected.service,
1585                                        iam_action,
1586                                        &aws_request.region,
1587                                    );
1588                                    // SQS keeps its own access-denied code per
1589                                    // protocol (its JSON path is query-compatible).
1590                                    if detected.service == "sqs" {
1591                                        return Some(access_denied_response(
1592                                            &message,
1593                                            request_id,
1594                                            detected,
1595                                            &aws_request.headers,
1596                                        ));
1597                                    }
1598                                    return Some(build_error_response(
1599                                        StatusCode::FORBIDDEN,
1600                                        "AccessDenied",
1601                                        &message,
1602                                        request_id,
1603                                        error_envelope,
1604                                    ));
1605                                }
1606                                // Soft mode: audit log emitted; fall through to the handler.
1607                            }
1608                        }
1609                    } else {
1610                        // Anonymous request to an iam_enforceable service whose
1611                        // operation has no IamAction mapping. Mirror the signed
1612                        // branch above: an operation we cannot map to an IAM action
1613                        // cannot be authorized, so serving it would be a fail-open
1614                        // bypass of `--iam strict`. Deny by default under strict;
1615                        // soft mode warns and falls through.
1616                        tracing::warn!(
1617                            target: "fakecloud::iam::audit",
1618                            service = %detected.service,
1619                            action = %aws_request.action,
1620                            mode = %config.iam_mode,
1621                            request_id = %request_id,
1622                            "anonymous request to iam_enforceable service has no IamAction mapping; denying under strict, allowing under soft"
1623                        );
1624                        if config.iam_mode.is_strict() {
1625                            return Some(build_error_response(
1626                                StatusCode::FORBIDDEN,
1627                                "AccessDenied",
1628                                "Access Denied",
1629                                request_id,
1630                                error_envelope,
1631                            ));
1632                        }
1633                    }
1634                }
1635            }
1636        }
1637        None
1638    }
1639}
1640
1641/// Run the request's handler, with its authorizer (when IAM enforcement
1642/// applies) available to it, and frame the response.
1643async fn finish_dispatch(
1644    service: Arc<dyn crate::service::AwsService>,
1645    aws_request: AwsRequest,
1646    authorizer: Option<Arc<dyn crate::auth::RequestAuthorizer>>,
1647    detected: protocol::DetectedRequest,
1648    request_id: String,
1649    error_envelope: ErrorEnvelope,
1650) -> Response<Body> {
1651    let handled = match authorizer {
1652        Some(authorizer) => {
1653            crate::auth::with_request_authorizer(authorizer, service.handle(aws_request)).await
1654        }
1655        None => service.handle(aws_request).await,
1656    };
1657    match handled {
1658        Ok(resp) => {
1659            let resp = if detected.protocol == AwsProtocol::RpcV2Cbor {
1660                rpc_v2_cbor_response(resp)
1661            } else {
1662                resp
1663            };
1664            let mut builder = Response::builder()
1665                .status(resp.status)
1666                .header("x-amzn-requestid", &request_id)
1667                .header("x-amz-request-id", &request_id);
1668
1669            if !resp.content_type.is_empty() {
1670                builder = builder.header("content-type", &resp.content_type);
1671            }
1672
1673            let has_content_length = resp
1674                .headers
1675                .iter()
1676                .any(|(k, _)| k.as_str().eq_ignore_ascii_case("content-length"));
1677
1678            for (k, v) in &resp.headers {
1679                builder = builder.header(k, v);
1680            }
1681
1682            match resp.body {
1683                ResponseBody::Bytes(b) => builder.body(Body::from(b)).unwrap(),
1684                ResponseBody::File { file, size } => {
1685                    let stream = tokio_util::io::ReaderStream::new(file);
1686                    let body = Body::from_stream(stream);
1687                    if !has_content_length {
1688                        builder = builder.header("content-length", size.to_string());
1689                    }
1690                    builder.body(body).unwrap()
1691                }
1692            }
1693        }
1694        Err(err) => service_error_response(&err, &detected, &request_id, error_envelope),
1695    }
1696}
1697
1698/// The HTTP response for an error a service answered a request with.
1699fn service_error_response(
1700    err: &crate::service::AwsServiceError,
1701    detected: &protocol::DetectedRequest,
1702    request_id: &str,
1703    error_envelope: ErrorEnvelope,
1704) -> Response<Body> {
1705    tracing::warn!(
1706        service = %detected.service,
1707        action = %detected.action,
1708        error = %err,
1709        "request failed"
1710    );
1711    let error_headers = err.response_headers().to_vec();
1712    let mut resp = build_error_response_with_fields(
1713        err.status(),
1714        err.code(),
1715        &err.message(),
1716        request_id,
1717        error_envelope,
1718        err.extra_fields(),
1719    );
1720    for (k, v) in &error_headers {
1721        if let (Ok(name), Ok(val)) = (
1722            k.parse::<http::header::HeaderName>(),
1723            v.parse::<http::header::HeaderValue>(),
1724        ) {
1725            // `Vary` combines, so two entries for it must both survive
1726            // (S3 adds its CORS value to an error that may carry its
1727            // own) — but an identical value is not worth repeating.
1728            // Everything else replaces, so a service can still override
1729            // a header this builder already set.
1730            if name == http::header::VARY {
1731                let already = resp
1732                    .headers()
1733                    .get_all(&name)
1734                    .iter()
1735                    .any(|existing| existing == val);
1736                if !already {
1737                    resp.headers_mut().append(name, val);
1738                }
1739            } else {
1740                resp.headers_mut().insert(name, val);
1741            }
1742        }
1743    }
1744    resp
1745}
1746
1747/// Frame a service response for an rpcv2Cbor caller. A service that renders
1748/// its own CBOR (content type `application/cbor`) passes through; a JSON body
1749/// is transcoded schema-lessly. Either way the response carries the
1750/// `smithy-protocol` header the client checks.
1751fn rpc_v2_cbor_response(mut resp: crate::service::AwsResponse) -> crate::service::AwsResponse {
1752    if resp.content_type != crate::cbor::CBOR_CONTENT_TYPE {
1753        if let ResponseBody::Bytes(bytes) = &resp.body {
1754            let json = if bytes.is_empty() {
1755                serde_json::Value::Object(serde_json::Map::new())
1756            } else {
1757                serde_json::from_slice(bytes).unwrap_or_else(|e| {
1758                    tracing::warn!(error = %e, "non-JSON response body for an rpcv2Cbor request");
1759                    serde_json::Value::Object(serde_json::Map::new())
1760                })
1761            };
1762            resp.body = ResponseBody::Bytes(Bytes::from(crate::cbor::encode(
1763                &crate::cbor::json_to_cbor(&json),
1764            )));
1765            resp.content_type = crate::cbor::CBOR_CONTENT_TYPE.to_string();
1766        }
1767    }
1768    resp.headers.insert(
1769        http::HeaderName::from_static(crate::cbor::SMITHY_PROTOCOL_HEADER),
1770        http::HeaderValue::from_static(crate::cbor::RPC_V2_CBOR),
1771    );
1772    resp
1773}
1774
1775/// Configuration passed to the dispatch handler.
1776#[derive(Clone)]
1777pub struct DispatchConfig {
1778    pub region: String,
1779    pub account_id: String,
1780    /// Whether to cryptographically verify SigV4 signatures on incoming
1781    /// requests. Wired through from `--verify-sigv4` /
1782    /// `FAKECLOUD_VERIFY_SIGV4`. Off by default.
1783    pub verify_sigv4: bool,
1784    /// IAM policy evaluation mode. Wired through from `--iam` /
1785    /// `FAKECLOUD_IAM`. Defaults to [`IamMode::Off`]. Actual evaluation is
1786    /// added in a later batch; today this field is plumbed but never
1787    /// consulted.
1788    pub iam_mode: IamMode,
1789    /// Resolves access key IDs to their secrets and owning principals.
1790    /// Required when `verify_sigv4` or `iam_mode != Off`. When `None`, both
1791    /// features gracefully degrade to off-by-default behavior.
1792    pub credential_resolver: Option<Arc<dyn CredentialResolver>>,
1793    /// Evaluates IAM identity policies for a resolved principal + action.
1794    /// Required when `iam_mode != Off`. When `None`, enforcement silently
1795    /// degrades to off even if `iam_mode` is set.
1796    pub policy_evaluator: Option<Arc<dyn IamPolicyEvaluator>>,
1797    /// Resolves resource-based policies (S3 bucket policies in the
1798    /// initial rollout) to hand to the evaluator alongside the
1799    /// principal's identity policies. `None` means the server was
1800    /// started without any resource-policy-owning service registered;
1801    /// dispatch then behaves as if no resource policy is attached to
1802    /// any resource, identical to the Phase 1 behavior.
1803    pub resource_policy_provider: Option<Arc<dyn ResourcePolicyProvider>>,
1804    /// Resolves the ordered SCP chain that applies to a principal's
1805    /// account (root-OU first, account-direct last). `None` means no
1806    /// organizations resolver has been registered — SCPs never gate
1807    /// any request in that case. Off-by-default matches the Batch 4
1808    /// contract: zero behavior change until a user calls
1809    /// `CreateOrganization` and the resolver is wired.
1810    pub scp_resolver: Option<Arc<dyn crate::auth::ScpResolver>>,
1811}
1812
1813impl std::fmt::Debug for DispatchConfig {
1814    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1815        f.debug_struct("DispatchConfig")
1816            .field("region", &self.region)
1817            .field("account_id", &self.account_id)
1818            .field("verify_sigv4", &self.verify_sigv4)
1819            .field("iam_mode", &self.iam_mode)
1820            .field(
1821                "credential_resolver",
1822                &self
1823                    .credential_resolver
1824                    .as_ref()
1825                    .map(|_| "<CredentialResolver>"),
1826            )
1827            .field(
1828                "policy_evaluator",
1829                &self
1830                    .policy_evaluator
1831                    .as_ref()
1832                    .map(|_| "<IamPolicyEvaluator>"),
1833            )
1834            .field(
1835                "resource_policy_provider",
1836                &self
1837                    .resource_policy_provider
1838                    .as_ref()
1839                    .map(|_| "<ResourcePolicyProvider>"),
1840            )
1841            .field(
1842                "scp_resolver",
1843                &self.scp_resolver.as_ref().map(|_| "<ScpResolver>"),
1844            )
1845            .finish()
1846    }
1847}
1848
1849impl DispatchConfig {
1850    /// Minimal constructor for tests and call sites that don't care about the
1851    /// opt-in security features.
1852    pub fn new(region: impl Into<String>, account_id: impl Into<String>) -> Self {
1853        Self {
1854            region: region.into(),
1855            account_id: account_id.into(),
1856            verify_sigv4: false,
1857            iam_mode: IamMode::Off,
1858            credential_resolver: None,
1859            policy_evaluator: None,
1860            resource_policy_provider: None,
1861            scp_resolver: None,
1862        }
1863    }
1864}
1865
1866/// The path an S3 request is routed on: for virtual-hosted-style the bucket
1867/// comes from the Host header and is prepended, unless the client already put
1868/// it in the path (`PUT /<bucket>` / `PUT /<bucket>/key` against a
1869/// virtual-hosted host). Path-style requests pass through unchanged.
1870///
1871/// Shared with [`streaming_route`] on purpose. The streaming gate has to agree
1872/// with routing about where the bucket ends and the key begins; when the two
1873/// derived it separately they drifted, and a request the router read as a
1874/// bucket-level operation was dispatched unbuffered, so the handler and IAM
1875/// enforcement saw an empty body.
1876///
1877/// Under virtual-hosted addressing the whole wire path is the object key, as
1878/// on real S3, so the bucket is always prefixed: on
1879/// `docs.s3.<region>.amazonaws.com`, `GET /docs/intro.html` addresses the key
1880/// `docs/intro.html`, not `intro.html`.
1881fn s3_routing_path(wire_path: &str, host_bucket: Option<&str>) -> String {
1882    let Some(bucket) = host_bucket else {
1883        return wire_path.to_string();
1884    };
1885    if wire_path == "/" || wire_path.is_empty() {
1886        format!("/{bucket}")
1887    } else {
1888        format!("/{bucket}{wire_path}")
1889    }
1890}
1891
1892/// Identifies requests that may stream their body. Returns
1893/// `Some((service, action_hint))` for a candidate, otherwise `None` for the
1894/// default buffered path. The handler reads the stream via
1895/// [`crate::service::AwsRequest::take_body_stream`].
1896///
1897/// Streaming candidates today:
1898///
1899/// * `s3` -- any `PUT` signed for S3 (SigV4 header, SigV4 or SigV2
1900///   presigned URL). Whether it streams is S3's decision
1901///   ([`crate::service::AwsService::streams_request_body`], asked by
1902///   [`service_streams_body`]): only an upload (PutObject, UploadPart)
1903///   does, by the classification its handler routes on.
1904/// * `ecr` OCI Distribution v2 blob upload -- `PATCH` and `PUT` on
1905///   `/v2/{name}/blobs/uploads/{uuid}`. The ECR service spools the
1906///   stream into a per-upload temp file before computing the digest.
1907fn streaming_route(
1908    method: &http::Method,
1909    path: &str,
1910    headers: &http::HeaderMap,
1911    query_params: &HashMap<String, String>,
1912) -> Option<(&'static str, &'static str)> {
1913    // ECR OCI v2 blob upload (PATCH chunk + final PUT).
1914    if (method == http::Method::PATCH || method == http::Method::PUT)
1915        && path.starts_with("/v2/")
1916        && path.contains("/blobs/uploads/")
1917    {
1918        return Some(("ecr", ""));
1919    }
1920
1921    // An S3 PUT, signed either via the SigV4 service field in the
1922    // Authorization header OR via a SigV4 presigned URL
1923    // (X-Amz-Credential .../s3/...) OR a SigV2 presigned URL
1924    // (AWSAccessKeyId + Signature + Expires query parameters).
1925    if method == http::Method::PUT {
1926        let header_s3 = headers
1927            .get("authorization")
1928            .and_then(|v| v.to_str().ok())
1929            .and_then(fakecloud_aws::sigv4::parse_sigv4)
1930            .map(|info| info.service == "s3")
1931            .unwrap_or(false);
1932        let presigned_v4_s3 = query_params
1933            .get("X-Amz-Credential")
1934            .and_then(|c| c.split('/').nth(3).map(|s| s.to_string()))
1935            .map(|service| service == "s3")
1936            .unwrap_or(false);
1937        let presigned_v2 = query_params.contains_key("AWSAccessKeyId")
1938            && query_params.contains_key("Signature")
1939            && query_params.contains_key("Expires");
1940        if header_s3 || presigned_v4_s3 || presigned_v2 {
1941            return Some(("s3", ""));
1942        }
1943    }
1944
1945    None
1946}
1947
1948/// What dispatch knows of a request before reading its body, to build the
1949/// [`crate::service::RequestHead`] a service judges it by.
1950struct EarlyRequest<'a> {
1951    method: &'a http::Method,
1952    wire_path: &'a str,
1953    headers: &'a http::HeaderMap,
1954    query_params: &'a HashMap<String, String>,
1955    anonymous: bool,
1956    region: &'a str,
1957    request_id: &'a str,
1958}
1959
1960/// Whether `service` reads this request's body as a stream, asked of the
1961/// service itself ([`with_request_head`]). Keeping the decision with the
1962/// service's own classification is what keeps the two from drifting: a
1963/// request dispatch streams but the handler reads buffered arrives with no
1964/// body, and one dispatch buffers but the handler streams is refused as
1965/// having no body stream.
1966fn service_streams_body(service: &dyn crate::service::AwsService, early: &EarlyRequest) -> bool {
1967    with_request_head(service, early, |head| service.streams_request_body(head))
1968}
1969
1970/// Run `f` on the request's head as its handler will see it: the S3 path
1971/// carries the virtual-hosted bucket ([`s3_routing_path`]) and a presigned
1972/// URL's `x-amz-*` query parameters count as headers
1973/// ([`hoist_presigned_query_headers`]), as they do once the request is
1974/// routed.
1975fn with_request_head<R>(
1976    service: &dyn crate::service::AwsService,
1977    early: &EarlyRequest,
1978    f: impl FnOnce(&crate::service::RequestHead<'_>) -> R,
1979) -> R {
1980    let EarlyRequest {
1981        method,
1982        wire_path,
1983        headers,
1984        query_params,
1985        anonymous,
1986        region,
1987        request_id,
1988    } = *early;
1989    let is_s3 = service.service_name() == "s3";
1990    let path = if is_s3 {
1991        let host_bucket = protocol::parse_routing_host_from_headers(headers)
1992            .filter(|h| h.service == "s3")
1993            .and_then(|h| h.bucket);
1994        s3_routing_path(wire_path, host_bucket.as_deref())
1995    } else {
1996        wire_path.to_string()
1997    };
1998    let hoisted;
1999    let headers = if is_s3 && query_params.contains_key("X-Amz-Credential") {
2000        let mut with_query = headers.clone();
2001        hoist_presigned_query_headers(&mut with_query, query_params);
2002        hoisted = with_query;
2003        &hoisted
2004    } else {
2005        headers
2006    };
2007    f(&crate::service::RequestHead {
2008        method,
2009        raw_path: &path,
2010        headers,
2011        query_params,
2012        anonymous,
2013        region,
2014        request_id,
2015    })
2016}
2017
2018/// The region a request is made in: its SigV4 scope (header or presigned
2019/// URL), else its host, else its user agent, else the server's.
2020fn request_region(
2021    headers: &http::HeaderMap,
2022    query_params: &HashMap<String, String>,
2023    default_region: &str,
2024) -> String {
2025    let auth_header = headers
2026        .get("authorization")
2027        .and_then(|v| v.to_str().ok())
2028        .unwrap_or("");
2029    fakecloud_aws::sigv4::parse_sigv4(auth_header)
2030        .or_else(|| fakecloud_aws::sigv4::parse_sigv4_presigned(query_params).map(|p| p.as_info()))
2031        .map(|info| info.region)
2032        .or_else(|| protocol::parse_routing_host_from_headers(headers).map(|h| h.region))
2033        .or_else(|| extract_region_from_user_agent(headers))
2034        .unwrap_or_else(|| default_region.to_string())
2035}
2036
2037/// Whether a request presents no credential: no `Authorization` header and
2038/// no query authentication (SigV4 `X-Amz-Credential`, a SigV2 presign's
2039/// `AWSAccessKeyId`, or SigV2 Query authentication). SigV2 Query
2040/// authentication in a form body is checked once the body is read.
2041fn presents_no_credential(
2042    headers: &http::HeaderMap,
2043    query_params: &HashMap<String, String>,
2044) -> bool {
2045    headers
2046        .get("authorization")
2047        .and_then(|v| v.to_str().ok())
2048        .unwrap_or("")
2049        .is_empty()
2050        && !query_params.contains_key("X-Amz-Credential")
2051        && sigv2_presigned_access_key(query_params).is_none()
2052        && sigv2_query_access_key(query_params).is_none()
2053}
2054
2055/// The SigV4 query-authentication parameters themselves. They authenticate
2056/// a presigned request and are not request headers.
2057const PRESIGN_AUTH_PARAMS: &[&str] = &[
2058    "x-amz-algorithm",
2059    "x-amz-credential",
2060    "x-amz-date",
2061    "x-amz-expires",
2062    "x-amz-signedheaders",
2063    "x-amz-signature",
2064    "x-amz-security-token",
2065];
2066
2067/// Copy the `x-amz-*` request headers a presigned URL carries in its query
2068/// string into `headers`, skipping the SigV4 auth parameters. A header the
2069/// client also sent directly wins over the query parameter. A non-ASCII user
2070/// metadata value is RFC 2047-encoded so S3 stores it as the UTF-8 the
2071/// percent-encoded query string meant, not as ISO-8859-1 header bytes.
2072fn hoist_presigned_query_headers(
2073    headers: &mut http::HeaderMap,
2074    query_params: &HashMap<String, String>,
2075) {
2076    for (key, value) in query_params {
2077        let lower = key.to_ascii_lowercase();
2078        if !lower.starts_with("x-amz-") || PRESIGN_AUTH_PARAMS.contains(&lower.as_str()) {
2079            continue;
2080        }
2081        let Ok(name) = http::HeaderName::from_bytes(lower.as_bytes()) else {
2082            continue;
2083        };
2084        if headers.contains_key(&name) {
2085            continue;
2086        }
2087        let value = if lower.starts_with("x-amz-meta-") {
2088            crate::rfc2047::encode(value)
2089        } else {
2090            value.clone()
2091        };
2092        if let Ok(value) = http::HeaderValue::from_str(&value) {
2093            headers.insert(name, value);
2094        }
2095    }
2096}
2097
2098/// Default request-body buffering cap. fakecloud reads the entire
2099/// request body into memory before handing it to a service handler,
2100/// so this ceiling caps RAM usage per in-flight request.
2101///
2102/// Default 1 GiB — comfortably above legitimate single S3 PutObject
2103/// payloads (AWS recommends multipart above ~100 MiB) and each
2104/// multipart part dispatches through here separately. Override with
2105/// `FAKECLOUD_MAX_REQUEST_BODY_BYTES` (decimal bytes) when running
2106/// stress tests that push past the default.
2107const DEFAULT_MAX_REQUEST_BODY_BYTES: usize = 1024 * 1024 * 1024;
2108
2109/// The server-wide buffered request body cap in bytes, from
2110/// `FAKECLOUD_MAX_REQUEST_BODY_BYTES` (default 1 GiB). Public so buffered
2111/// sub-proxies (e.g. the CloudFront viewer data plane) apply the SAME cap as
2112/// direct traffic rather than an inconsistent limit of their own.
2113pub fn max_request_body_bytes() -> usize {
2114    static CACHED: std::sync::OnceLock<usize> = std::sync::OnceLock::new();
2115    *CACHED.get_or_init(|| {
2116        std::env::var("FAKECLOUD_MAX_REQUEST_BODY_BYTES")
2117            .ok()
2118            .and_then(|s| s.parse::<usize>().ok())
2119            .filter(|&n| n > 0)
2120            .unwrap_or(DEFAULT_MAX_REQUEST_BODY_BYTES)
2121    })
2122}
2123
2124/// For the cross-account decision in IAM enforcement, the "resource
2125/// account" is the ARN's account segment. Some services (notably S3)
2126/// produce ARNs with an empty account field — for those we return
2127/// `None` and let the caller fall back to the server's configured
2128/// account ID. Malformed or non-ARN strings also return `None`.
2129fn parse_account_from_arn(arn: &str) -> Option<String> {
2130    let mut parts = arn.splitn(6, ':');
2131    if parts.next()? != "arn" {
2132        return None;
2133    }
2134    let _partition = parts.next()?;
2135    let _service = parts.next()?;
2136    let _region = parts.next()?;
2137    let account = parts.next()?;
2138    // Resource segment must exist (parts.next().is_some()) for the ARN
2139    // to be well-formed, but we don't consume its value here.
2140    parts.next()?;
2141    if account.is_empty() {
2142        None
2143    } else {
2144        Some(account.to_string())
2145    }
2146}
2147
2148/// Whether the request's `user-agent` (or `x-amz-user-agent`) carries the
2149/// `api/neptune` SDK-metadata token the `aws-sdk-neptune` client stamps in.
2150/// Used to disambiguate Neptune from RDS, which share the `rds` SigV4 scope
2151/// and endpoint. Matches the `api/neptune` token that AWS SDKs emit (a
2152/// leading `api/neptune` optionally followed by `#`/`/` and a version).
2153fn user_agent_indicates_neptune(headers: &http::HeaderMap) -> bool {
2154    for name in ["user-agent", "x-amz-user-agent"] {
2155        if let Some(ua) = headers.get(name).and_then(|v| v.to_str().ok()) {
2156            for part in ua.split_whitespace() {
2157                if let Some(rest) = part.strip_prefix("api/neptune") {
2158                    if rest.is_empty() || rest.starts_with('#') || rest.starts_with('/') {
2159                        return true;
2160                    }
2161                }
2162            }
2163        }
2164    }
2165    false
2166}
2167
2168/// Extract region from User-Agent header suffix `region/<region>`.
2169/// Whether the request's `user-agent` (or `x-amz-user-agent`) carries the
2170/// `api/docdb` SDK-metadata token the `aws-sdk-docdb` client stamps in. Used
2171/// to disambiguate DocumentDB from RDS, which share the `rds` SigV4 scope
2172/// and endpoint. Matches the `api/docdb` token that AWS SDKs emit (a leading
2173/// `api/docdb` optionally followed by `#`/`/` and a version).
2174fn user_agent_indicates_docdb(headers: &http::HeaderMap) -> bool {
2175    for name in ["user-agent", "x-amz-user-agent"] {
2176        if let Some(ua) = headers.get(name).and_then(|v| v.to_str().ok()) {
2177            for part in ua.split_whitespace() {
2178                if let Some(rest) = part.strip_prefix("api/docdb") {
2179                    if rest.is_empty() || rest.starts_with('#') || rest.starts_with('/') {
2180                        return true;
2181                    }
2182                }
2183            }
2184        }
2185    }
2186    false
2187}
2188
2189fn extract_region_from_user_agent(headers: &http::HeaderMap) -> Option<String> {
2190    let ua = headers.get("user-agent")?.to_str().ok()?;
2191    for part in ua.split_whitespace() {
2192        if let Some(region) = part.strip_prefix("region/") {
2193            if !region.is_empty() {
2194                return Some(region.to_string());
2195            }
2196        }
2197    }
2198    None
2199}
2200
2201/// The wire shape of an error body. It follows the protocol, except within
2202/// REST-XML: S3 answers with a bare `<Error>` document, while CloudFront and
2203/// Route 53 wrap theirs in a namespaced `<ErrorResponse>`. The AWS SDKs parse
2204/// each service's errors strictly against its own shape.
2205#[derive(Clone, Copy, Debug)]
2206struct ErrorEnvelope {
2207    protocol: AwsProtocol,
2208    /// `Some(xmlns)` selects the `<ErrorResponse>` REST-XML shape.
2209    rest_xml_namespace: Option<&'static str>,
2210    /// An S3 Control request (`s3-control` host, served by the `s3`
2211    /// handler): its errors use S3 Control's un-namespaced `<ErrorResponse>`.
2212    s3_control: bool,
2213    /// A REST-JSON service whose errors use Lambda's body shape.
2214    lambda_body: bool,
2215    /// An awsQuery service's namespace and `<Detail/>`.
2216    query: fakecloud_aws::error::QueryErrorShape,
2217}
2218
2219impl ErrorEnvelope {
2220    /// The envelope for an error answering a request routed to `detected`.
2221    fn for_request(detected: &protocol::DetectedRequest, headers: &http::HeaderMap) -> Self {
2222        let rest_xml_namespace = if detected.protocol == AwsProtocol::Rest {
2223            fakecloud_aws::error::rest_xml_error_namespace(&detected.service)
2224        } else {
2225            None
2226        };
2227        Self {
2228            protocol: detected.protocol,
2229            rest_xml_namespace,
2230            s3_control: detected.protocol == AwsProtocol::Rest
2231                && detected.service == "s3"
2232                && protocol::is_s3_control_host(headers),
2233            lambda_body: detected.protocol == AwsProtocol::RestJson
2234                && fakecloud_aws::error::uses_lambda_error_body(&detected.service),
2235            query: if detected.protocol == AwsProtocol::Query {
2236                fakecloud_aws::error::QueryErrorShape::for_service(&detected.service)
2237            } else {
2238                Default::default()
2239            },
2240        }
2241    }
2242}
2243
2244impl From<AwsProtocol> for ErrorEnvelope {
2245    fn from(protocol: AwsProtocol) -> Self {
2246        Self {
2247            protocol,
2248            rest_xml_namespace: None,
2249            s3_control: false,
2250            lambda_body: false,
2251            query: Default::default(),
2252        }
2253    }
2254}
2255
2256fn build_error_response(
2257    status: StatusCode,
2258    code: &str,
2259    message: &str,
2260    request_id: &str,
2261    envelope: impl Into<ErrorEnvelope>,
2262) -> Response<Body> {
2263    build_error_response_with_fields(status, code, message, request_id, envelope, &[])
2264}
2265
2266fn build_error_response_with_fields(
2267    status: StatusCode,
2268    code: &str,
2269    message: &str,
2270    request_id: &str,
2271    envelope: impl Into<ErrorEnvelope>,
2272    extra_fields: &[(String, String)],
2273) -> Response<Body> {
2274    let envelope = envelope.into();
2275    let (status, content_type, body) = match (envelope.protocol, envelope.rest_xml_namespace) {
2276        // awsQuery services (SQS, SNS, IAM, STS, RDS, ELBv2, CloudWatch,
2277        // AutoScaling, ...) share the `<ErrorResponse>` envelope.
2278        (AwsProtocol::Query, _) => fakecloud_aws::error::query_error_response(
2279            status,
2280            code,
2281            message,
2282            request_id,
2283            envelope.query,
2284        ),
2285        // EC2 uses the distinct ec2Query error envelope
2286        // (`<Response><Errors><Error>...</Errors><RequestID>`). Only EC2 is
2287        // classified `Ec2Query`, so the other Query-protocol services above are
2288        // untouched.
2289        (AwsProtocol::Ec2Query, _) => {
2290            fakecloud_aws::ec2query::ec2_error_response(status, code, message, request_id)
2291        }
2292        // CloudFront and Route 53 wrap the error in a namespaced
2293        // `<ErrorResponse>`; S3 (and the REST-XML fallbacks) use a bare `<Error>`.
2294        (AwsProtocol::Rest, Some(namespace)) => fakecloud_aws::error::rest_xml_error_response(
2295            status, code, message, request_id, namespace,
2296        ),
2297        (AwsProtocol::Rest, None) if envelope.s3_control => {
2298            fakecloud_aws::error::s3_control_xml_error_response(
2299                status,
2300                code,
2301                message,
2302                request_id,
2303                extra_fields,
2304            )
2305        }
2306        (AwsProtocol::Rest, None) => fakecloud_aws::error::s3_xml_error_response_with_fields(
2307            status,
2308            code,
2309            message,
2310            request_id,
2311            extra_fields,
2312        ),
2313        (AwsProtocol::RestJson, _) if envelope.lambda_body => {
2314            fakecloud_aws::error::lambda_json_error_response(status, code, message, extra_fields)
2315        }
2316        (AwsProtocol::Json | AwsProtocol::RestJson, _) => {
2317            fakecloud_aws::error::json_error_response_with_fields(
2318                status,
2319                code,
2320                message,
2321                extra_fields,
2322            )
2323        }
2324        (AwsProtocol::RpcV2Cbor, _) => (
2325            status,
2326            crate::cbor::CBOR_CONTENT_TYPE.to_string(),
2327            Bytes::from(crate::cbor::error_body(code, message, extra_fields)),
2328        ),
2329    };
2330
2331    // S3 (and other REST-XML services) place the error code in
2332    // `x-amz-error-code` so HEAD responses — which HTTP forbids from
2333    // carrying a body — still surface the code. AWS SDKs read this header
2334    // when the body is empty. Emit it on every error response so HEAD,
2335    // OPTIONS, and any client that strips the body still see the code.
2336    // Backend errors regularly include newlines (multi-line stderr from
2337    // docker/podman/etc.); HTTP header values reject control characters,
2338    // so sanitize before insertion or the builder rejects the response
2339    // and the connection drops.
2340    let safe_code = sanitize_header_value(code);
2341    let safe_message = sanitize_header_value(message);
2342    let mut builder = Response::builder()
2343        .status(status)
2344        .header("content-type", content_type)
2345        .header("x-amzn-requestid", request_id)
2346        .header("x-amz-request-id", request_id);
2347    if let Ok(v) = http::HeaderValue::from_str(&safe_code) {
2348        builder = builder.header("x-amz-error-code", v);
2349    }
2350    if let Ok(v) = http::HeaderValue::from_str(&safe_message) {
2351        builder = builder.header("x-amz-error-message", v);
2352    }
2353    // restJson1 services name the error in `x-amzn-ErrorType`, the header
2354    // the SDKs read first (Lambda's bodies carry no code at all).
2355    if envelope.protocol == AwsProtocol::RestJson {
2356        if let Ok(v) = http::HeaderValue::from_str(&safe_code) {
2357            builder = builder.header("x-amzn-errortype", v);
2358        }
2359    }
2360    if envelope.protocol == AwsProtocol::RpcV2Cbor {
2361        builder = builder.header(
2362            crate::cbor::SMITHY_PROTOCOL_HEADER,
2363            crate::cbor::RPC_V2_CBOR,
2364        );
2365    }
2366    builder.body(Body::from(body)).unwrap_or_else(|_| {
2367        // Builder only fails if a header is invalid; we sanitized the two
2368        // we control, so the remaining ones (content-type, request id) are
2369        // ASCII and safe. This fallback exists purely so we never panic.
2370        Response::new(Body::empty())
2371    })
2372}
2373
2374/// Strip characters that HTTP header values reject (control bytes, CR/LF/TAB)
2375/// and truncate to a length that AWS SDKs handle cleanly. Backend tools
2376/// (docker, podman, kubectl, …) emit multi-line stderr, and forwarding that
2377/// raw into `x-amz-error-message` previously panicked the dispatcher.
2378fn sanitize_header_value(s: &str) -> String {
2379    const MAX_LEN: usize = 1024;
2380    let mut out = String::with_capacity(s.len().min(MAX_LEN));
2381    for ch in s.chars() {
2382        if out.len() >= MAX_LEN {
2383            break;
2384        }
2385        // Header values forbid CR, LF, and other control bytes (RFC 9110).
2386        // Replace with a single space so multi-line messages stay readable.
2387        if ch.is_control() {
2388            if !out.ends_with(' ') {
2389                out.push(' ');
2390            }
2391        } else {
2392            out.push(ch);
2393        }
2394    }
2395    out.trim().to_string()
2396}
2397
2398/// Build the [`ConditionContext`] passed to the IAM evaluator for one
2399/// request. Populates the 10 global condition keys from the resolved
2400/// principal + the HTTP request. Service-specific keys are deferred to
2401/// a follow-up batch and left empty.
2402/// For an unsigned request that no other detection rule claimed, return the
2403/// bucket name when the first path segment names an existing S3 bucket.
2404///
2405/// fakecloud serves every service from one endpoint, so an anonymous
2406/// path-style S3 request (`GET /bucket/key`, no SigV4) is indistinguishable
2407/// from an API Gateway execute-api call by headers alone. Bucket existence is
2408/// the disambiguator: if the segment is a real bucket, route to S3; otherwise
2409/// fall through to the apigateway catch-all. Uses the already-wired
2410/// `resource_policy_provider`, which resolves S3 bucket ownership from state
2411/// (`Some` => the bucket exists). Returns `None` when no provider is wired.
2412/// Recover the access key from a SigV2 presigned URL. AWS SigV2 presigning
2413/// puts the key in the `AWSAccessKeyId` query parameter alongside `Signature`
2414/// and `Expires`; all three must be present for the URL to be a SigV2 presign.
2415/// Returns None for SigV4 presigns (which use `X-Amz-Credential`) or unsigned
2416/// requests.
2417fn sigv2_presigned_access_key(query_params: &HashMap<String, String>) -> Option<String> {
2418    if query_params.contains_key("Signature") && query_params.contains_key("Expires") {
2419        query_params.get("AWSAccessKeyId").cloned()
2420    } else {
2421        None
2422    }
2423}
2424
2425/// Recover the access key from SigV2 Query authentication (`AWSAccessKeyId`,
2426/// `Signature`, `SignatureVersion=2`, `SignatureMethod`, and a `Timestamp`
2427/// or `Expires`), carried in the query string or a form body. The request is
2428/// signed by that key like a SigV4 one; its SigV2 signature is not verified
2429/// (`--verify-sigv4` verifies SigV4 only).
2430fn sigv2_query_access_key(params: &HashMap<String, String>) -> Option<String> {
2431    (params.get("SignatureVersion").map(String::as_str) == Some("2")
2432        && params.contains_key("Signature"))
2433    .then(|| params.get("AWSAccessKeyId").cloned())
2434    .flatten()
2435    .filter(|key| !key.is_empty())
2436}
2437
2438/// True when `s` is a 64-character lowercase-hex SHA-256 digest — the form a
2439/// signed `x-amz-content-sha256` payload hash takes. Distinguishes a real body
2440/// hash from the `UNSIGNED-PAYLOAD` / `STREAMING-*` markers (shorter and
2441/// containing non-hex characters), so only genuine hashes are re-bound to the
2442/// buffered body.
2443fn is_hex_sha256(s: &str) -> bool {
2444    s.len() == 64 && s.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
2445}
2446
2447/// Lowercase-hex SHA-256 of `bytes`, used to re-bind a signed
2448/// `x-amz-content-sha256` to the buffered request body for non-S3 services.
2449fn sha256_hex_lower(bytes: &[u8]) -> String {
2450    use sha2::{Digest, Sha256};
2451    let digest = Sha256::digest(bytes);
2452    const HEX: &[u8] = b"0123456789abcdef";
2453    let mut out = String::with_capacity(64);
2454    for b in digest {
2455        out.push(HEX[(b >> 4) as usize] as char);
2456        out.push(HEX[(b & 0x0f) as usize] as char);
2457    }
2458    out
2459}
2460
2461/// The condition context for evaluating `iam_action` as `principal`: the
2462/// principal's global keys, the session keys its credential carries (MFA,
2463/// token issue time, federated provider), the service's own condition keys,
2464/// and the resource / request / principal tags for ABAC. Callers add the
2465/// keys that depend on the resource's owner (see [`add_global_request_keys`]).
2466#[allow(clippy::too_many_arguments)]
2467fn principal_condition_context(
2468    principal: &Principal,
2469    resolved: Option<&crate::auth::ResolvedCredential>,
2470    service: &dyn crate::service::AwsService,
2471    aws_request: &AwsRequest,
2472    iam_action: &crate::auth::IamAction,
2473    detected: &protocol::DetectedRequest,
2474    remote_addr: Option<SocketAddr>,
2475) -> ConditionContext {
2476    let mut ctx = build_condition_context(
2477        principal,
2478        remote_addr,
2479        &aws_request.region,
2480        is_secure_transport(&aws_request.headers),
2481    );
2482    // F3 keys riding on the resolved credential. STS
2483    // populates these at mint time so subsequent
2484    // requests under the credential can be evaluated
2485    // against `aws:MultiFactorAuthPresent`,
2486    // `aws:MultiFactorAuthAge`, `aws:TokenIssueTime`,
2487    // and `aws:FederatedProvider`. IAM user access
2488    // keys carry none of these, matching AWS.
2489    if let Some(rc) = resolved {
2490        // An assumed-role session's `aws:PrincipalArn` is its role's ARN
2491        // (path included), not the session ARN.
2492        if let Some(role_arn) = rc.role_arn.as_ref() {
2493            ctx.aws_principal_arn = Some(role_arn.clone());
2494        }
2495        ctx.federated_parent_arn = rc.federated_parent_arn.clone();
2496        ctx.aws_mfa_present = Some(rc.mfa_present);
2497        ctx.aws_token_issue_time = rc.token_issued_at;
2498        ctx.aws_federated_provider = rc.federated_provider.clone();
2499        // `aws:MultiFactorAuthAge` is "seconds since
2500        // MFA was asserted" — computed at evaluation
2501        // time from the token issue moment so the
2502        // value increases monotonically as the session
2503        // ages. Only set when the session was actually
2504        // minted with MFA; otherwise the key is
2505        // absent, matching AWS.
2506        if rc.mfa_present {
2507            if let Some(issued) = rc.token_issued_at {
2508                let age = chrono::Utc::now()
2509                    .signed_duration_since(issued)
2510                    .num_seconds()
2511                    .max(0);
2512                ctx.aws_mfa_age_seconds = Some(age);
2513            }
2514        }
2515    }
2516    ctx.service_keys = service.iam_condition_keys_for(aws_request, iam_action);
2517
2518    // ABAC: populate tag-based condition keys.
2519    // aws:ResourceTag/*. An `iam:PassRole` resource is
2520    // an IAM role, not one of this service's resources,
2521    // so the service has no tags (or resource policy)
2522    // for it.
2523    let service_resource = !iam_action.is_pass_role();
2524    match service_resource
2525        .then(|| service.resource_tags_for(&iam_action.resource))
2526        .flatten()
2527    {
2528        Some(tags) => ctx.resource_tags = Some(tags),
2529        None => tracing::debug!(
2530            target: "fakecloud::iam::audit",
2531            service = %detected.service,
2532            resource = %iam_action.resource,
2533            "service does not expose resource tags for ABAC; skipping aws:ResourceTag/* evaluation"
2534        ),
2535    }
2536    // aws:RequestTag/* + aws:TagKeys
2537    match service.request_tags_from(aws_request, iam_action.action) {
2538        Some(tags) => ctx.request_tags = Some(tags),
2539        None => tracing::debug!(
2540            target: "fakecloud::iam::audit",
2541            service = %detected.service,
2542            action = %iam_action.action_string(),
2543            "service does not expose request tags for ABAC; skipping aws:RequestTag/* / aws:TagKeys evaluation"
2544        ),
2545    }
2546    // aws:PrincipalTag/*
2547    ctx.principal_tags = principal.tags.clone();
2548    ctx
2549}
2550
2551/// [`global_request_keys`] for a request dispatch received directly from
2552/// `principal`.
2553fn add_global_request_keys(
2554    ctx: &mut ConditionContext,
2555    principal: &Principal,
2556    resource_account_id: &str,
2557    scp_resolver: Option<&dyn crate::auth::ScpResolver>,
2558) {
2559    global_request_keys(ctx, &principal.account_id, resource_account_id, |account| {
2560        scp_resolver.and_then(|r| r.principal_org(account))
2561    });
2562}
2563
2564/// Populate the global keys AWS sets on every request from who makes it and
2565/// what it is made on: `aws:ResourceAccount` (the account owning the target
2566/// resource), `aws:PrincipalOrgID` / `aws:PrincipalOrgPaths` when the
2567/// principal's account (`principal_account`) is in an organization,
2568/// `aws:ResourceOrgID` / `aws:ResourceOrgPaths` when the resource's account
2569/// is, and `aws:ViaAWSService` (`false` unless a service set it for a call it
2570/// forwards). `org_of` gives an account's organization id and path. A value
2571/// already present wins.
2572pub fn global_request_keys(
2573    ctx: &mut ConditionContext,
2574    principal_account: &str,
2575    resource_account_id: &str,
2576    org_of: impl Fn(&str) -> Option<(String, String)>,
2577) {
2578    let mut set = |key: &str, value: String| {
2579        ctx.service_keys
2580            .entry(key.to_string())
2581            .or_insert_with(|| vec![value]);
2582    };
2583    if !resource_account_id.is_empty() {
2584        set("aws:resourceaccount", resource_account_id.to_string());
2585        if let Some((org_id, path)) = org_of(resource_account_id) {
2586            set("aws:resourceorgid", org_id);
2587            set("aws:resourceorgpaths", path);
2588        }
2589    }
2590    if let Some((org_id, path)) = org_of(principal_account) {
2591        set("aws:principalorgid", org_id);
2592        set("aws:principalorgpaths", path);
2593    }
2594    set("aws:viaawsservice", "false".to_string());
2595}
2596
2597/// The AWS error for a request whose access key resolves to no identity:
2598/// `ExpiredToken` for a temporary credential that has expired, otherwise
2599/// `InvalidClientTokenId` (unknown or deactivated key, revoked session).
2600fn unresolved_credential_response(
2601    config: &DispatchConfig,
2602    access_key_id: &str,
2603    request_id: &str,
2604    envelope: ErrorEnvelope,
2605) -> Response<Body> {
2606    let expired = config
2607        .credential_resolver
2608        .as_ref()
2609        .is_some_and(|r| r.is_expired(access_key_id));
2610    let (code, message) = if expired {
2611        (
2612            "ExpiredToken",
2613            "The security token included in the request is expired",
2614        )
2615    } else {
2616        invalid_token_error(&envelope)
2617    };
2618    build_error_response(StatusCode::FORBIDDEN, code, message, request_id, envelope)
2619}
2620
2621/// The code and message for an access key that resolves to no identity.
2622/// Lambda answers `UnrecognizedClientException` with a trailing period
2623/// (recorded in us-east-1); the other services keep `InvalidClientTokenId`.
2624fn invalid_token_error(envelope: &ErrorEnvelope) -> (&'static str, &'static str) {
2625    if envelope.lambda_body {
2626        (
2627            "UnrecognizedClientException",
2628            "The security token included in the request is invalid.",
2629        )
2630    } else {
2631        (
2632            "InvalidClientTokenId",
2633            "The security token included in the request is invalid",
2634        )
2635    }
2636}
2637
2638/// The service for a request no protocol signal (target header, `Action`,
2639/// signing scope, AWS endpoint host) routed, in this order:
2640///
2641/// 1. API Gateway when its data plane claims the `Host` (execute-api or custom
2642///    domain): nothing sent to such a host is S3's, whatever the path.
2643/// 2. S3 for any other `OPTIONS` (CORS preflight; API Gateway answers its own
2644///    on its hosts above).
2645/// 3. ECR for the OCI Distribution `/v2` API (Basic auth, no SigV4).
2646/// 4. S3 when the first path segment names an existing bucket (#1707), which
2647///    beats a stage of the same name.
2648/// 5. S3 when the request carries S3 query authentication (SigV2 or SigV4
2649///    parameters, even an incomplete set, which S3 then rejects as AWS does),
2650///    or is fully unsigned and API Gateway does not claim its path
2651///    (`/restapis/...`, `/_aws/execute-api/...`, a deployed stage): an
2652///    unsigned path-style request to S3's endpoint, existing bucket or not.
2653/// 6. API Gateway for the rest outside `/_*`: claimed paths and signed requests
2654///    whose scope named no REST service (an IAM-auth API signed for
2655///    `execute-api`), plus LocalStack's `/_aws/execute-api/...` URL.
2656///
2657/// `None` for an unrouted fakecloud endpoint (`/_*`).
2658fn unrouted_target(
2659    method: &http::Method,
2660    uri: &http::Uri,
2661    headers: &http::HeaderMap,
2662    query_params: &HashMap<String, String>,
2663    registry: &ServiceRegistry,
2664    config: &DispatchConfig,
2665) -> Option<&'static str> {
2666    use crate::service::DataPlaneClaim;
2667    let path = uri.path();
2668    let claim = registry
2669        .get("apigateway")
2670        .and_then(|apigw| apigw.claim_unrouted_request(method, path, headers, &config.account_id));
2671    if claim == Some(DataPlaneClaim::Host)
2672        || (*method == http::Method::OPTIONS && is_execute_api_host(headers))
2673    {
2674        return Some("apigateway");
2675    }
2676    if *method == http::Method::OPTIONS {
2677        return Some("s3");
2678    }
2679    if path == "/v2" || path.starts_with("/v2/") {
2680        return Some("ecr");
2681    }
2682    if let Some(bucket) = anonymous_s3_bucket(uri, config) {
2683        tracing::debug!(bucket = %bucket, "routing unrouted request to S3 (existing bucket)");
2684        return Some("s3");
2685    }
2686    let fakecloud_endpoint = path.starts_with("/_");
2687    let s3_takes_it = match claim {
2688        // A path API Gateway claims (a deployed stage, an invocation URL)
2689        // stays the API's unless the query is unmistakably S3 auth.
2690        Some(_) => is_unmistakable_s3_query_auth(headers, query_params),
2691        None => has_s3_query_auth(headers, query_params) || is_unsigned(headers, query_params),
2692    };
2693    if !fakecloud_endpoint && registry.get("s3").is_some() && s3_takes_it {
2694        return Some("s3");
2695    }
2696    if !fakecloud_endpoint || path.starts_with("/_aws/execute-api/") {
2697        return Some("apigateway");
2698    }
2699    None
2700}
2701
2702/// Whether a query-string `Action` is only a parameter of an unsigned request
2703/// that is S3's or API Gateway's by what only dispatch can tell: on the plain
2704/// host, a path addressing an existing bucket (`GET /pubb/k.txt?Action=...`
2705/// is the object), or a request API Gateway claims: a custom-domain `Host`,
2706/// a deployed stage's path, or a path-style invocation URL. (An S3 or
2707/// execute-api host or signature scope, or an S3 SigV2 presign, is
2708/// [`protocol::query_action_is_a_parameter`].) An SQS queue path whose first
2709/// segment is an account ID is the queue's unless a bucket of that name
2710/// exists.
2711fn unsigned_action_is_a_parameter(
2712    method: &http::Method,
2713    uri: &http::Uri,
2714    headers: &http::HeaderMap,
2715    query_params: &HashMap<String, String>,
2716    registry: &ServiceRegistry,
2717    config: &DispatchConfig,
2718) -> bool {
2719    if !is_unsigned(headers, query_params) || headers.contains_key("x-amz-target") {
2720        return false;
2721    }
2722    let plain_host = protocol::parse_routing_host_from_headers(headers).is_none();
2723    (plain_host && anonymous_s3_bucket(uri, config).is_some())
2724        || registry.get("apigateway").is_some_and(|apigw| {
2725            // Any claim, by host or by path (a deployed stage, a path-style
2726            // invocation URL): a Query call on the plain host goes to `/`,
2727            // which API Gateway never claims.
2728            apigw
2729                .claim_unrouted_request(method, uri.path(), headers, &config.account_id)
2730                .is_some()
2731        })
2732}
2733
2734/// The detection for a service [`unrouted_target`] picked.
2735fn unrouted_detection(service: &str) -> protocol::DetectedRequest {
2736    protocol::DetectedRequest {
2737        service: service.to_string(),
2738        action: String::new(),
2739        protocol: if service == "apigateway" {
2740            AwsProtocol::RestJson
2741        } else {
2742            AwsProtocol::Rest
2743        },
2744    }
2745}
2746
2747/// Whether the request carries no credential at all: no `Authorization`
2748/// header and no SigV4 or SigV2 query-authentication parameter, not even an
2749/// incomplete set (`AWSAccessKeyId` + `Signature` without `Expires` is a
2750/// broken presign, not an anonymous request).
2751fn is_unsigned(headers: &http::HeaderMap, query_params: &HashMap<String, String>) -> bool {
2752    const QUERY_AUTH: &[&str] = &[
2753        "X-Amz-Credential",
2754        "X-Amz-Signature",
2755        "AWSAccessKeyId",
2756        "Signature",
2757    ];
2758    headers
2759        .get(http::header::AUTHORIZATION)
2760        .is_none_or(|v| v.as_bytes().is_empty())
2761        && !QUERY_AUTH.iter().any(|k| query_params.contains_key(*k))
2762}
2763
2764/// Whether an `Authorization`-less request carries S3 query authentication:
2765/// any SigV2 parameter (`AWSAccessKeyId`, `Signature`) or SigV4 one whose
2766/// credential scope, if readable, is S3's (a scope naming another service is
2767/// that service's presign, e.g. `execute-api`).
2768fn has_s3_query_auth(headers: &http::HeaderMap, query_params: &HashMap<String, String>) -> bool {
2769    if headers
2770        .get(http::header::AUTHORIZATION)
2771        .is_some_and(|v| !v.as_bytes().is_empty())
2772    {
2773        return false;
2774    }
2775    if query_params.contains_key("AWSAccessKeyId") || query_params.contains_key("Signature") {
2776        return true;
2777    }
2778    let sigv4 = query_params.contains_key("X-Amz-Credential")
2779        || query_params.contains_key("X-Amz-Signature")
2780        || query_params.contains_key("X-Amz-Algorithm");
2781    sigv4
2782        && query_params
2783            .get("X-Amz-Credential")
2784            .and_then(|c| c.split('/').nth(3))
2785            .is_none_or(|service| service == "s3")
2786}
2787
2788/// Whether an `Authorization`-less request's query is unmistakably S3 query
2789/// authentication -- a SigV4 `X-Amz-Credential` scoped to `s3`, or at least
2790/// two of the SigV2 trio (`AWSAccessKeyId`, `Signature`, `Expires`) -- rather
2791/// than a stray parameter an API invocation may carry (`?Signature=abc`).
2792fn is_unmistakable_s3_query_auth(
2793    headers: &http::HeaderMap,
2794    query_params: &HashMap<String, String>,
2795) -> bool {
2796    if headers
2797        .get(http::header::AUTHORIZATION)
2798        .is_some_and(|v| !v.as_bytes().is_empty())
2799    {
2800        return false;
2801    }
2802    let sigv4_s3 = query_params
2803        .get("X-Amz-Credential")
2804        .and_then(|c| c.split('/').nth(3))
2805        .is_some_and(|service| service == "s3");
2806    let sigv2_parts = ["AWSAccessKeyId", "Signature", "Expires"]
2807        .iter()
2808        .filter(|k| query_params.contains_key(**k))
2809        .count();
2810    sigv4_s3 || sigv2_parts >= 2
2811}
2812
2813/// The `AccessDenied` S3 answers before anything else (credential lookup
2814/// included) for SigV2 query authentication it cannot accept, as recorded
2815/// against us-east-1, with the extra `<Error>` elements it carries:
2816///
2817/// - an incomplete parameter set (`AWSAccessKeyId` / `Signature` / `Expires`,
2818///   any missing): "Query-string authentication requires the Signature,
2819///   Expires and AWSAccessKeyId parameters";
2820/// - an `Expires` that is not a 32-bit signed epoch-seconds value
2821///   (`2147483647` passes; `2147483648`, `9999999999`, `abc` do not):
2822///   "Invalid date (should be seconds since epoch): <value>";
2823/// - an `Expires` in the past (`-1` included): "Request has expired", with
2824///   `<Expires>` (the expiry) and `<ServerTime>` (now), both ISO-8601 UTC.
2825///
2826/// Requests with an `Authorization` header, or with no SigV2 parameter, are
2827/// not SigV2 query authentication.
2828fn s3_sigv2_query_auth_error(
2829    headers: &http::HeaderMap,
2830    query_params: &HashMap<String, String>,
2831    now: chrono::DateTime<chrono::Utc>,
2832) -> Option<(String, Vec<(String, String)>)> {
2833    if headers.contains_key(http::header::AUTHORIZATION)
2834        || !(query_params.contains_key("AWSAccessKeyId") || query_params.contains_key("Signature"))
2835    {
2836        return None;
2837    }
2838    let (Some(_), Some(_), Some(expires)) = (
2839        query_params.get("AWSAccessKeyId"),
2840        query_params.get("Signature"),
2841        query_params.get("Expires"),
2842    ) else {
2843        return Some((
2844            "Query-string authentication requires the Signature, Expires and AWSAccessKeyId \
2845             parameters"
2846                .to_string(),
2847            Vec::new(),
2848        ));
2849    };
2850    let Ok(secs) = expires.parse::<i32>() else {
2851        return Some((
2852            format!("Invalid date (should be seconds since epoch): {expires}"),
2853            Vec::new(),
2854        ));
2855    };
2856    let expiry = chrono::DateTime::from_timestamp(i64::from(secs), 0)?;
2857    (expiry < now).then(|| {
2858        (
2859            "Request has expired".to_string(),
2860            vec![
2861                ("Expires".to_string(), s3_error_time(expiry)),
2862                ("ServerTime".to_string(), s3_error_time(now)),
2863            ],
2864        )
2865    })
2866}
2867
2868/// The `AccessDenied` message for an anonymous request authorization refused.
2869/// S3 refuses an anonymous `CreateBucket` outright with its own message
2870/// (recorded in us-east-1); everything else is S3's generic "Access Denied".
2871/// SQS (recorded): a queue operation names the lowercased action and the
2872/// queue ARN ("User: anonymous is not authorized to perform:
2873/// sqs:receivemessage on resource: arn:aws:sqs:...:q because no
2874/// resource-based policy allows the sqs:receivemessage action"); the
2875/// service-level `ListQueues` names the endpoint ("Access to the resource
2876/// https://sqs.us-east-1.amazonaws.com/ is denied.").
2877fn anonymous_denied_message(
2878    service: &str,
2879    action: &crate::auth::IamAction,
2880    region: &str,
2881) -> std::borrow::Cow<'static, str> {
2882    if service == "s3" && action.service == "s3" && action.action == "CreateBucket" {
2883        "Anonymous users cannot invoke this API. Please authenticate.".into()
2884    } else if service == "sqs" {
2885        if action.resource.starts_with("arn:") {
2886            let name = format!("sqs:{}", action.action.to_ascii_lowercase());
2887            format!(
2888                "User: anonymous is not authorized to perform: {name} on resource: {} \
2889                 because no resource-based policy allows the {name} action",
2890                action.resource,
2891            )
2892            .into()
2893        } else {
2894            format!(
2895                "Access to the resource https://sqs.{region}.{}/ is denied.",
2896                fakecloud_aws::endpoint::dns_suffix_for_region(region)
2897            )
2898            .into()
2899        }
2900    } else {
2901        "Access Denied".into()
2902    }
2903}
2904
2905/// Whether an unsigned request names an API operation of the service it was
2906/// routed to, so it is judged for its missing credentials: an awsJson target,
2907/// a Query `Action`, or a REST request addressed to the service's own API
2908/// host (`lambda.us-east-1.amazonaws.com`, `route53.amazonaws.com`, ...) of a
2909/// service whose API is REST, which the service classifies as one of its
2910/// control-plane operations (`is_control_plane`,
2911/// [`crate::service::AwsService::is_control_plane_request`]). Anything else is
2912/// a data-plane request and is never refused here: an API Gateway invocation
2913/// (on any host), an ECR registry call (ECR's API is awsJson, so a REST ECR
2914/// request is its OCI registry), an S3 path-style object. A CORS preflight
2915/// (`OPTIONS`) is never an API call either: browsers never sign one.
2916fn names_api_operation(
2917    detected: &protocol::DetectedRequest,
2918    host: Option<&protocol::RoutingHost>,
2919    method: &http::Method,
2920    headers: &http::HeaderMap,
2921    is_control_plane: impl FnOnce() -> bool,
2922) -> bool {
2923    if *method == http::Method::OPTIONS {
2924        return false;
2925    }
2926    match detected.protocol {
2927        AwsProtocol::Json | AwsProtocol::Query | AwsProtocol::Ec2Query | AwsProtocol::RpcV2Cbor => {
2928            !detected.action.is_empty()
2929        }
2930        AwsProtocol::Rest | AwsProtocol::RestJson => {
2931            protocol::is_rest_api_service(&detected.service)
2932                && host.is_some_and(|h| h.service == detected.service && h.bucket.is_none())
2933                && !is_execute_api_host(headers)
2934                && is_control_plane()
2935        }
2936    }
2937}
2938
2939/// The refusal of an unsigned request to an operation that needs
2940/// credentials, as each service answers it (recorded in us-east-1,
2941/// `tests/fixtures/anonymous-requests/README.md`):
2942///
2943/// - awsQuery: 403 `MissingAuthenticationToken` "Request is missing
2944///   Authentication Token" (STS, IAM, SNS);
2945/// - EC2: 400 `MissingParameter` "The request must contain the parameter
2946///   AWSAccessKeyId";
2947/// - awsJson: 400 `MissingAuthenticationTokenException` "Missing
2948///   Authentication Token" (KMS, SSM, CloudWatch Logs); DynamoDB's names the
2949///   coral namespace, `com.amazon.coral.service#MissingAuthenticationTokenException`
2950///   "Request is missing Authentication Token"; Secrets Manager answers 400
2951///   `UnrecognizedClientException` "The security token included in the
2952///   request is invalid.";
2953/// - restJson1: 403 `{"message":"Missing Authentication Token"}` (Lambda);
2954/// - restXml: 403 `MissingAuthenticationToken` "Request is missing
2955///   Authentication Token" in the service's error document (Route 53).
2956///
2957/// Services not recorded get their protocol's shape above.
2958fn missing_authentication_response(
2959    detected: &protocol::DetectedRequest,
2960    request_id: &str,
2961    headers: &http::HeaderMap,
2962) -> Response<Body> {
2963    const QUERY_MESSAGE: &str = "Request is missing Authentication Token";
2964    const JSON_MESSAGE: &str = "Missing Authentication Token";
2965    let envelope = ErrorEnvelope::for_request(detected, headers);
2966    let (status, code, message, envelope) = match (detected.service.as_str(), detected.protocol) {
2967        (_, AwsProtocol::Ec2Query) => (
2968            StatusCode::BAD_REQUEST,
2969            "MissingParameter",
2970            "The request must contain the parameter AWSAccessKeyId",
2971            envelope,
2972        ),
2973        ("dynamodb", AwsProtocol::Json | AwsProtocol::RpcV2Cbor) => (
2974            StatusCode::BAD_REQUEST,
2975            "com.amazon.coral.service#MissingAuthenticationTokenException",
2976            QUERY_MESSAGE,
2977            envelope,
2978        ),
2979        ("secretsmanager", AwsProtocol::Json | AwsProtocol::RpcV2Cbor) => (
2980            StatusCode::BAD_REQUEST,
2981            "UnrecognizedClientException",
2982            "The security token included in the request is invalid.",
2983            envelope,
2984        ),
2985        (_, AwsProtocol::Json | AwsProtocol::RpcV2Cbor) => (
2986            StatusCode::BAD_REQUEST,
2987            "MissingAuthenticationTokenException",
2988            JSON_MESSAGE,
2989            envelope,
2990        ),
2991        (_, AwsProtocol::Query | AwsProtocol::Rest) => (
2992            StatusCode::FORBIDDEN,
2993            "MissingAuthenticationToken",
2994            QUERY_MESSAGE,
2995            envelope,
2996        ),
2997        // The restJson1 front door answers with a bare `message` body, the
2998        // code in `x-amzn-ErrorType` (Lambda's error body shape).
2999        (_, AwsProtocol::RestJson) => (
3000            StatusCode::FORBIDDEN,
3001            "MissingAuthenticationTokenException",
3002            JSON_MESSAGE,
3003            ErrorEnvelope {
3004                lambda_body: true,
3005                ..envelope
3006            },
3007        ),
3008    };
3009    build_error_response(status, code, message, request_id, envelope)
3010}
3011
3012/// A timestamp as S3 writes it in an error document (`1969-12-31T23:59:59Z`).
3013fn s3_error_time(t: chrono::DateTime<chrono::Utc>) -> String {
3014    t.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
3015}
3016
3017fn anonymous_s3_bucket(uri: &http::Uri, config: &DispatchConfig) -> Option<String> {
3018    let provider = config.resource_policy_provider.as_ref()?;
3019    let segment = uri.path().split('/').find(|s| !s.is_empty())?.to_string();
3020    let arn = fakecloud_aws::arn::Arn::s3(&segment).to_string();
3021    provider.resource_owner_account("s3", &arn).map(|_| segment)
3022}
3023
3024/// The account an [`InternalCaller`] request works in: for S3, the owner of
3025/// the addressed bucket (bucket names are global, and a bucket policy can
3026/// grant a service acting for another account's resource); otherwise, or
3027/// when the bucket does not exist, the account the caller acts for.
3028fn internal_caller_account(
3029    caller: &InternalCaller,
3030    service: &str,
3031    path_segments: &[String],
3032    config: &DispatchConfig,
3033) -> String {
3034    let bucket_owner = (service == "s3")
3035        .then(|| path_segments.first())
3036        .flatten()
3037        .and_then(|bucket| {
3038            let arn = fakecloud_aws::arn::Arn::s3(bucket).to_string();
3039            config
3040                .resource_policy_provider
3041                .as_ref()?
3042                .resource_owner_account("s3", &arn)
3043        });
3044    bucket_owner.unwrap_or_else(|| caller.acting_account().to_string())
3045}
3046
3047/// Authorize a request fakecloud makes in-process as an AWS-owned principal
3048/// (see [`InternalCaller`]). Such a principal has no identity policies and no
3049/// account, so -- as in AWS -- only the resource policy (S3 bucket policy) can
3050/// grant it the action, with a public-read ACL honored the way it is for any
3051/// caller. The request context carries the caller's keys (`aws:SourceArn`,
3052/// `aws:SourceAccount`, ...) so a confused-deputy condition scoped to the
3053/// acting resource matches. `aws:SourceIp` is absent, as AWS omits it for a
3054/// request a service makes.
3055///
3056/// Returns the error response to send when the request is denied under
3057/// strict mode; soft mode only logs.
3058#[allow(clippy::too_many_arguments)]
3059fn authorize_internal_caller(
3060    caller: &InternalCaller,
3061    service: &dyn crate::service::AwsService,
3062    aws_request: &AwsRequest,
3063    evaluator: &dyn IamPolicyEvaluator,
3064    config: &DispatchConfig,
3065    detected: &protocol::DetectedRequest,
3066    request_id: &str,
3067    iam_actions: &[crate::auth::IamAction],
3068    deferred: bool,
3069) -> Option<Response<Body>> {
3070    let principal = caller.principal();
3071    let denied = || {
3072        config.iam_mode.is_strict().then(|| {
3073            build_error_response(
3074                StatusCode::FORBIDDEN,
3075                "AccessDenied",
3076                "Access Denied",
3077                request_id,
3078                ErrorEnvelope::for_request(detected, &aws_request.headers),
3079            )
3080        })
3081    };
3082    if iam_actions.is_empty() && !deferred {
3083        tracing::warn!(
3084            target: "fakecloud::iam::audit",
3085            service = %detected.service,
3086            action = %aws_request.action,
3087            principal = %principal.arn,
3088            mode = %config.iam_mode,
3089            request_id = %request_id,
3090            "service-principal request has no IamAction mapping; denying under strict, allowing under soft"
3091        );
3092        return denied();
3093    }
3094    // A service acting on AWS's behalf is not subject to `iam:PassRole`:
3095    // that check binds the customer principal handing a role to a service.
3096    for iam_action in iam_actions.iter().filter(|a| !a.is_pass_role()) {
3097        let now = chrono::Utc::now();
3098        let mut context = ConditionContext {
3099            aws_principal_arn: Some(principal.arn.clone()),
3100            aws_current_time: Some(now),
3101            aws_epoch_time: Some(now.timestamp()),
3102            aws_secure_transport: Some(is_secure_transport(&aws_request.headers)),
3103            aws_requested_region: Some(aws_request.region.clone()),
3104            ..Default::default()
3105        };
3106        context.service_keys = service.iam_condition_keys_for(aws_request, iam_action);
3107        context.service_keys.extend(caller.condition_keys());
3108        // `aws:ResourceAccount`: the account the service works in for this
3109        // request (for S3, the addressed bucket's owner).
3110        context
3111            .service_keys
3112            .entry("aws:resourceaccount".to_string())
3113            .or_insert_with(|| vec![aws_request.account_id.clone()]);
3114        let resource_policy_json = config
3115            .resource_policy_provider
3116            .as_ref()
3117            .and_then(|p| p.resource_policy(&detected.service, &iam_action.resource));
3118        let decision = evaluator.evaluate_resource_policy_only(
3119            &principal,
3120            iam_action,
3121            &context,
3122            resource_policy_json.as_deref(),
3123        );
3124        let explicit_deny = matches!(decision, crate::auth::IamDecision::ExplicitDeny);
3125        let acl_allows = !explicit_deny
3126            && config.resource_policy_provider.as_ref().is_some_and(|p| {
3127                p.public_acl_allows(&detected.service, &iam_action.resource, iam_action.action)
3128            });
3129        if !decision.is_allow() && !acl_allows {
3130            tracing::warn!(
3131                target: "fakecloud::iam::audit",
3132                service = %detected.service,
3133                action = %iam_action.action_string(),
3134                resource = %iam_action.resource,
3135                principal = %principal.arn,
3136                resource_policy_present = resource_policy_json.is_some(),
3137                decision = ?decision,
3138                mode = %config.iam_mode,
3139                request_id = %request_id,
3140                "service-principal request denied: the resource policy does not grant the action"
3141            );
3142            if let Some(resp) = denied() {
3143                return Some(resp);
3144            }
3145        }
3146    }
3147    None
3148}
3149
3150/// Whether a grant on `action.resource` authorizes a request its policies
3151/// left implicitly denied (see [`ResourcePolicyProvider::grant_authorization`]).
3152/// A grant naming the caller itself authorizes it without an identity
3153/// policy, in the key's account or another, but never past the caller's SCP
3154/// ceiling; a grant naming the role of an assumed-role session is also
3155/// capped by the role's permissions boundary and the session's session
3156/// policies (a grant naming the user or session itself is not). One naming
3157/// the caller's account
3158/// only lets the account delegate, so the caller's identity policies (with
3159/// their boundary, session and SCP gates) must also allow the action.
3160#[allow(clippy::too_many_arguments)]
3161fn grant_authorizes(
3162    provider: Option<&dyn ResourcePolicyProvider>,
3163    evaluator: &dyn IamPolicyEvaluator,
3164    service: &str,
3165    request: &AwsRequest,
3166    action: &crate::auth::IamAction,
3167    principal: &Principal,
3168    context: &ConditionContext,
3169    session_policies: &[String],
3170    scps: Option<&[String]>,
3171) -> bool {
3172    let Some(provider) = provider else {
3173        return false;
3174    };
3175    match provider.grant_authorization(service, request, action, principal) {
3176        Some(crate::auth::GrantAuthorization::Grantee) => evaluator
3177            .evaluate_ceiling(principal, action, context, &[], scps, false)
3178            .is_allow(),
3179        Some(crate::auth::GrantAuthorization::GranteeRole) => evaluator
3180            .evaluate_ceiling(principal, action, context, session_policies, scps, true)
3181            .is_allow(),
3182        Some(crate::auth::GrantAuthorization::Account) => evaluator
3183            .evaluate(principal, action, context, session_policies, scps)
3184            .is_allow(),
3185        None => false,
3186    }
3187}
3188
3189/// Authorize a request made as an account's root user: its SCPs and its
3190/// session's policies first, then, for a resource of another account, that
3191/// resource's policy (see [`authorize_root_cross_account`]).
3192///
3193/// Returns the error response to send when the request is denied under
3194/// strict mode; soft mode only logs.
3195#[allow(clippy::too_many_arguments)]
3196fn authorize_root(
3197    principal: &Principal,
3198    service: &dyn crate::service::AwsService,
3199    aws_request: &AwsRequest,
3200    evaluator: &dyn IamPolicyEvaluator,
3201    config: &DispatchConfig,
3202    detected: &protocol::DetectedRequest,
3203    request_id: &str,
3204    remote_addr: Option<SocketAddr>,
3205    resolved: Option<&crate::auth::ResolvedCredential>,
3206    iam_actions: &[crate::auth::IamAction],
3207    deferred: bool,
3208) -> Option<Response<Body>> {
3209    authorize_root_scps(
3210        principal,
3211        service,
3212        aws_request,
3213        evaluator,
3214        config,
3215        detected,
3216        request_id,
3217        remote_addr,
3218        resolved,
3219        iam_actions,
3220        deferred,
3221    )
3222    .or_else(|| {
3223        authorize_root_cross_account(
3224            principal,
3225            service,
3226            aws_request,
3227            evaluator,
3228            config,
3229            detected,
3230            request_id,
3231            remote_addr,
3232            resolved,
3233            iam_actions,
3234        )
3235    })
3236}
3237
3238/// Cap a root-user request at the account's SCPs and at the policies of the
3239/// session it is made with.
3240///
3241/// Root needs no identity policy in its own account, but SCPs restrict
3242/// "IAM users and roles in member accounts, including the member account's
3243/// root user" (Organizations user guide, "Service control policies"), and
3244/// AWS evaluates them before any resource-based policy, so a resource policy
3245/// granting root in another account does not reach past them either. The
3246/// resolver exempts the management account (and returns nothing outside an
3247/// organization), which keeps root there unrestricted. A root session minted
3248/// by `sts:AssumeRoot` carries its task policy as its session policy, which
3249/// scopes it to that privileged task.
3250#[allow(clippy::too_many_arguments)]
3251fn authorize_root_scps(
3252    principal: &Principal,
3253    service: &dyn crate::service::AwsService,
3254    aws_request: &AwsRequest,
3255    evaluator: &dyn IamPolicyEvaluator,
3256    config: &DispatchConfig,
3257    detected: &protocol::DetectedRequest,
3258    request_id: &str,
3259    remote_addr: Option<SocketAddr>,
3260    resolved: Option<&crate::auth::ResolvedCredential>,
3261    iam_actions: &[crate::auth::IamAction],
3262    deferred: bool,
3263) -> Option<Response<Body>> {
3264    let resolver = config.scp_resolver.as_deref();
3265    let scps = resolver.and_then(|r| r.scps_for(principal));
3266    let session_policies = resolved.map_or(&[][..], |r| r.session_policies.as_slice());
3267    if scps.is_none() && session_policies.is_empty() {
3268        return None;
3269    }
3270    let in_caller_account = service.iam_resource_in_caller_account(aws_request);
3271    if iam_actions.is_empty() && !deferred {
3272        // An operation with no IAM action mapping cannot be checked against
3273        // the SCPs, so under strict enforcement it fails closed, as it does
3274        // for every other principal.
3275        tracing::warn!(
3276            target: "fakecloud::iam::audit",
3277            service = %detected.service,
3278            action = %aws_request.action,
3279            principal = %principal.arn,
3280            mode = %config.iam_mode,
3281            request_id = %request_id,
3282            "root request under SCPs or session policies has no IamAction mapping; denying under strict, allowing under soft"
3283        );
3284        return config
3285            .iam_mode
3286            .is_strict()
3287            .then(|| unmapped_action_denial(principal, aws_request, request_id, detected));
3288    }
3289    for iam_action in iam_actions {
3290        let resource_account = config
3291            .resource_policy_provider
3292            .as_ref()
3293            .filter(|_| !iam_action.is_pass_role() && !in_caller_account)
3294            .and_then(|p| p.resource_owner_account(&detected.service, &iam_action.resource))
3295            .or_else(|| parse_account_from_arn(&iam_action.resource))
3296            .unwrap_or_else(|| principal.account_id.clone());
3297        let mut context = principal_condition_context(
3298            principal,
3299            resolved,
3300            service,
3301            aws_request,
3302            iam_action,
3303            detected,
3304            remote_addr,
3305        );
3306        add_global_request_keys(&mut context, principal, &resource_account, resolver);
3307        let decision = evaluator.evaluate_ceiling(
3308            principal,
3309            iam_action,
3310            &context,
3311            session_policies,
3312            scps.as_deref(),
3313            false,
3314        );
3315        if decision.is_allow() {
3316            continue;
3317        }
3318        tracing::warn!(
3319            target: "fakecloud::iam::audit",
3320            service = %detected.service,
3321            action = %iam_action.action_string(),
3322            resource = %iam_action.resource,
3323            principal = %principal.arn,
3324            decision = ?decision,
3325            mode = %config.iam_mode,
3326            request_id = %request_id,
3327            "root request denied by the account's service control policies or its session policies"
3328        );
3329        if config.iam_mode.is_strict() {
3330            let action = iam_action.action_string();
3331            // Name the SCPs only when they are what refused the request.
3332            let scp_reason = scps.as_deref().and_then(|scps| {
3333                let ceiling = evaluator.evaluate_ceiling(
3334                    principal,
3335                    iam_action,
3336                    &context,
3337                    &[],
3338                    Some(scps),
3339                    false,
3340                );
3341                scp_cause(decision, ceiling).and_then(|d| scp_denial_reason(d, &action))
3342            });
3343            return Some(access_denied_response(
3344                &not_authorized_message(
3345                    &principal.arn,
3346                    &action,
3347                    &iam_action.resource,
3348                    scp_reason.as_deref(),
3349                    None,
3350                ),
3351                request_id,
3352                detected,
3353                &aws_request.headers,
3354            ));
3355        }
3356    }
3357    None
3358}
3359
3360/// AWS's wording for a request the SCPs refuse, given the SCP ceiling's
3361/// `decision`: `with an explicit deny in a service control policy`, or
3362/// `because no service control policy allows the <action> action`. `None`
3363/// when the SCPs allow the request.
3364fn scp_denial_reason(decision: crate::auth::IamDecision, action: &str) -> Option<String> {
3365    match decision {
3366        crate::auth::IamDecision::Allow => None,
3367        crate::auth::IamDecision::ExplicitDeny => {
3368            Some("with an explicit deny in a service control policy".to_string())
3369        }
3370        crate::auth::IamDecision::ImplicitDeny => Some(format!(
3371            "because no service control policy allows the {action} action"
3372        )),
3373    }
3374}
3375
3376/// The SCP ceiling's decision when the SCP layer itself caused a denial
3377/// whose overall decision was `decision`: an SCP that explicitly denies, or
3378/// one that doesn't allow when nothing else explicitly denied (an explicit
3379/// Deny elsewhere is the cause then). `None` when the SCPs are not the
3380/// cause.
3381fn scp_cause(
3382    decision: crate::auth::IamDecision,
3383    ceiling: crate::auth::IamDecision,
3384) -> Option<crate::auth::IamDecision> {
3385    use crate::auth::IamDecision;
3386    match (decision, ceiling) {
3387        (_, IamDecision::ExplicitDeny) => Some(IamDecision::ExplicitDeny),
3388        (IamDecision::ImplicitDeny, IamDecision::ImplicitDeny) => Some(IamDecision::ImplicitDeny),
3389        _ => None,
3390    }
3391}
3392
3393/// The message of an `AccessDeniedException` for `principal_arn`:
3394/// `User: <arn> is not authorized to perform: <action> on resource:
3395/// <resource>`, then the `reason` the request was refused when one is known
3396/// (an SCP's), then the encoded authorization failure message if any.
3397fn not_authorized_message(
3398    principal_arn: &str,
3399    action: &str,
3400    resource: &str,
3401    reason: Option<&str>,
3402    encoded: Option<&str>,
3403) -> String {
3404    let mut message = format!(
3405        "User: {principal_arn} is not authorized to perform: {action} on resource: {resource}"
3406    );
3407    if let Some(reason) = reason {
3408        message.push(' ');
3409        message.push_str(reason);
3410    }
3411    if let Some(encoded) = encoded {
3412        message.push_str(" Encoded authorization failure message: ");
3413        message.push_str(encoded);
3414    }
3415    message
3416}
3417
3418/// The error code a caller its policies don't authorize is answered with,
3419/// by the request's protocol: `AccessDenied` on the awsQuery protocol (IAM,
3420/// STS, SQS's query path, ELBv2, ...) and for S3, `AccessDeniedException` on
3421/// the JSON and REST protocols (their declared code), except SNS, whose
3422/// declared 403 is `AuthorizationError`.
3423fn access_denied_code(service: &str, protocol: AwsProtocol) -> &'static str {
3424    match (service, protocol) {
3425        ("sns", _) => "AuthorizationError",
3426        ("s3", _) | (_, AwsProtocol::Query) => "AccessDenied",
3427        _ => "AccessDeniedException",
3428    }
3429}
3430
3431/// Services that answer the JSON protocol `awsQueryCompatible`: their JSON
3432/// errors carry the awsQuery code in `x-amzn-query-error`, which SDKs report
3433/// as the error's code.
3434const QUERY_COMPATIBLE_JSON_SERVICES: &[&str] = &["sqs"];
3435
3436/// The strict-mode refusal of a request its caller is not authorized for,
3437/// shaped as the service answers it (see [`access_denied_code`]). On the
3438/// JSON path of an awsQuery-compatible service (SQS), the body keeps the
3439/// JSON `AccessDeniedException` and `x-amzn-query-error: AccessDenied;Sender`
3440/// carries the awsQuery code, as SQS does for its own errors.
3441fn access_denied_response(
3442    message: &str,
3443    request_id: &str,
3444    detected: &protocol::DetectedRequest,
3445    headers: &http::HeaderMap,
3446) -> Response<Body> {
3447    let mut resp = build_error_response(
3448        StatusCode::FORBIDDEN,
3449        access_denied_code(&detected.service, detected.protocol),
3450        message,
3451        request_id,
3452        ErrorEnvelope::for_request(detected, headers),
3453    );
3454    if detected.protocol == AwsProtocol::Json
3455        && QUERY_COMPATIBLE_JSON_SERVICES.contains(&detected.service.as_str())
3456    {
3457        resp.headers_mut().insert(
3458            "x-amzn-query-error",
3459            http::HeaderValue::from_static("AccessDenied;Sender"),
3460        );
3461    }
3462    resp
3463}
3464
3465/// The strict-mode refusal of an operation with no IAM action mapping,
3466/// which cannot be authorized.
3467fn unmapped_action_denial(
3468    principal: &Principal,
3469    aws_request: &AwsRequest,
3470    request_id: &str,
3471    detected: &protocol::DetectedRequest,
3472) -> Response<Body> {
3473    access_denied_response(
3474        &format!(
3475            "User: {} is not authorized to perform: {}: no IAM action mapping exists for this operation, so it cannot be authorized under strict IAM enforcement",
3476            principal.arn, aws_request.action,
3477        ),
3478        request_id,
3479        detected,
3480        &aws_request.headers,
3481    )
3482}
3483
3484/// Authorize an account root reaching a resource ANOTHER account owns.
3485///
3486/// Root needs no identity policy in its own account, which is why dispatch
3487/// exempts it from identity evaluation. That exemption ends at the account
3488/// boundary: on AWS, root of account B reaching account A's bucket (or table)
3489/// is a cross-account request like any other, allowed only when A's resource
3490/// policy grants it. This applies where the resource policy provider resolves
3491/// the owner of a resource (S3 buckets, whose names are global and whose ARNs
3492/// carry no account, and DynamoDB tables and streams) -- the resource-policy
3493/// governed data services whose handlers serve a request in the owner's
3494/// account. A same-account resource, or one no provider claims, keeps the
3495/// root exemption.
3496///
3497/// Returns the error response to send when the request is denied under
3498/// strict mode; soft mode only logs.
3499#[allow(clippy::too_many_arguments)]
3500fn authorize_root_cross_account(
3501    principal: &Principal,
3502    service: &dyn crate::service::AwsService,
3503    aws_request: &AwsRequest,
3504    evaluator: &dyn IamPolicyEvaluator,
3505    config: &DispatchConfig,
3506    detected: &protocol::DetectedRequest,
3507    request_id: &str,
3508    remote_addr: Option<SocketAddr>,
3509    resolved: Option<&crate::auth::ResolvedCredential>,
3510    iam_actions: &[crate::auth::IamAction],
3511) -> Option<Response<Body>> {
3512    if service.iam_resource_in_caller_account(aws_request) {
3513        return None;
3514    }
3515    let provider = config.resource_policy_provider.as_ref()?;
3516    for iam_action in iam_actions {
3517        let Some(owner) = provider.resource_owner_account(&detected.service, &iam_action.resource)
3518        else {
3519            continue;
3520        };
3521        if owner == principal.account_id {
3522            continue;
3523        }
3524        let mut context = principal_condition_context(
3525            principal,
3526            resolved,
3527            service,
3528            aws_request,
3529            iam_action,
3530            detected,
3531            remote_addr,
3532        );
3533        add_global_request_keys(
3534            &mut context,
3535            principal,
3536            &owner,
3537            config.scp_resolver.as_deref(),
3538        );
3539        let resource_policy_json = provider
3540            .resource_policy_applies(&detected.service, iam_action)
3541            .then(|| provider.resource_policy(&detected.service, &iam_action.resource))
3542            .flatten();
3543        let decision = evaluator.evaluate_resource_policy_only(
3544            principal,
3545            iam_action,
3546            &context,
3547            resource_policy_json.as_deref(),
3548        );
3549        let explicit_deny = matches!(decision, crate::auth::IamDecision::ExplicitDeny);
3550        let acl_allows = !explicit_deny
3551            && provider.public_acl_allows(
3552                &detected.service,
3553                &iam_action.resource,
3554                iam_action.action,
3555            );
3556        // A grant naming the root or its account authorizes it: the root
3557        // holds every permission of its own account, so a grant to the
3558        // account needs no further IAM delegation.
3559        let grant_allows = decision == crate::auth::IamDecision::ImplicitDeny
3560            && provider
3561                .grant_authorization(&detected.service, aws_request, iam_action, principal)
3562                .is_some();
3563        if decision.is_allow() || acl_allows || grant_allows {
3564            continue;
3565        }
3566        tracing::warn!(
3567            target: "fakecloud::iam::audit",
3568            service = %detected.service,
3569            action = %iam_action.action_string(),
3570            resource = %iam_action.resource,
3571            principal = %principal.arn,
3572            resource_account = %owner,
3573            resource_policy_present = resource_policy_json.is_some(),
3574            decision = ?decision,
3575            mode = %config.iam_mode,
3576            request_id = %request_id,
3577            "cross-account root request denied: the resource policy does not grant the action"
3578        );
3579        if config.iam_mode.is_strict() {
3580            return Some(access_denied_response(
3581                &format!(
3582                    "User: {} is not authorized to perform: {} on resource: {} because no resource-based policy allows the {} action",
3583                    principal.arn,
3584                    iam_action.action_string(),
3585                    iam_action.resource,
3586                    iam_action.action_string(),
3587                ),
3588                request_id,
3589                detected,
3590                &aws_request.headers,
3591            ));
3592        }
3593    }
3594    None
3595}
3596
3597fn build_condition_context(
3598    principal: &Principal,
3599    remote_addr: Option<SocketAddr>,
3600    region: &str,
3601    secure_transport: bool,
3602) -> ConditionContext {
3603    let now = chrono::Utc::now();
3604    ConditionContext {
3605        aws_username: aws_username_from_principal(principal),
3606        aws_userid: Some(principal.user_id.clone()),
3607        aws_principal_arn: Some(principal.arn.clone()),
3608        aws_principal_account: Some(principal.account_id.clone()),
3609        aws_principal_type: Some(principal_type_label(principal.principal_type).to_string()),
3610        aws_source_ip: remote_addr.map(|sa| sa.ip()),
3611        aws_current_time: Some(now),
3612        aws_epoch_time: Some(now.timestamp()),
3613        aws_secure_transport: Some(secure_transport),
3614        aws_requested_region: Some(region.to_string()),
3615        // F3 keys: populated from the caller's session context when STS
3616        // mints credentials with MFA / SAML / OIDC / VPC-endpoint hints.
3617        // Default-None here so tests/dispatch sites that don't set them
3618        // safe-fail any policy referencing them — matching AWS for keys
3619        // that aren't asserted.
3620        aws_mfa_present: None,
3621        aws_mfa_age_seconds: None,
3622        aws_called_via: Vec::new(),
3623        aws_source_vpce: None,
3624        aws_source_vpc: None,
3625        aws_vpc_source_ip: None,
3626        aws_federated_provider: None,
3627        aws_token_issue_time: None,
3628        service_keys: Default::default(),
3629        resource_tags: None,
3630        request_tags: None,
3631        principal_tags: None,
3632        federated_parent_arn: None,
3633    }
3634}
3635
3636/// `aws:username` is only set for IAM users, matching AWS. For assumed
3637/// roles, federated users, root, and unknown principals the key is
3638/// absent — operators that reference it without `IfExists` safe-fail.
3639fn aws_username_from_principal(principal: &Principal) -> Option<String> {
3640    if principal.principal_type != PrincipalType::User {
3641        return None;
3642    }
3643    let after = principal.arn.rsplit_once(":user/").map(|(_, s)| s)?;
3644    // Strip any IAM path prefix; bare username is the last segment.
3645    Some(after.rsplit('/').next().unwrap_or(after).to_string())
3646}
3647
3648/// AWS's `aws:PrincipalType` uses PascalCase identifiers, distinct from
3649/// the lowercase ones [`PrincipalType::as_str`] returns for ARNs.
3650pub fn principal_type_label(t: PrincipalType) -> &'static str {
3651    match t {
3652        PrincipalType::User => "User",
3653        PrincipalType::AssumedRole => "AssumedRole",
3654        PrincipalType::FederatedUser => "FederatedUser",
3655        PrincipalType::Root => "Account",
3656        PrincipalType::Unknown => "Unknown",
3657        PrincipalType::Service => "Service",
3658    }
3659}
3660
3661/// Best-effort detection of TLS-terminated requests. Direct HTTPS
3662/// connections are not yet supported by the fakecloud server (it speaks
3663/// plain HTTP), so the only signal is an `x-forwarded-proto: https`
3664/// header set by an upstream proxy. Anything else evaluates to `false`,
3665/// which matches the typical local-dev setup.
3666fn is_secure_transport(headers: &http::HeaderMap) -> bool {
3667    headers
3668        .get("x-forwarded-proto")
3669        .and_then(|v| v.to_str().ok())
3670        .map(|s| s.eq_ignore_ascii_case("https"))
3671        .unwrap_or(false)
3672}
3673
3674trait ProtocolExt {
3675    fn error_status(&self) -> StatusCode;
3676}
3677
3678impl ProtocolExt for AwsProtocol {
3679    fn error_status(&self) -> StatusCode {
3680        StatusCode::BAD_REQUEST
3681    }
3682}
3683
3684/// Whether a (possibly percent-encoded) `/tags/{resourceArn}` label is the ARN
3685/// of a Bedrock Agents runtime session (`...:session/<id>`).
3686fn names_bedrock_session(label: &str) -> bool {
3687    let decoded = label
3688        .to_ascii_lowercase()
3689        .replace("%3a", ":")
3690        .replace("%2f", "/");
3691    decoded.starts_with("arn:") && decoded.contains(":bedrock:") && decoded.contains(":session/")
3692}
3693
3694/// Which Bedrock Agents handler a `bedrock`-scoped request belongs to:
3695/// `bedrock-agent-runtime` or `bedrock-agent` for their path families, `None`
3696/// for any other Bedrock (bedrock-runtime / control plane) path. Runtime and
3697/// control plane share `/agents/...` and `/flows/...` prefixes, so the split
3698/// is by path shape and, where the shapes coincide, by method.
3699fn bedrock_agent_service_for(method: &http::Method, path: &str) -> Option<&'static str> {
3700    let first_seg = path.split('/').nth(1);
3701    if !matches!(
3702        first_seg,
3703        Some(
3704            "agents"
3705                | "knowledgebases"
3706                | "flows"
3707                | "prompts"
3708                | "tags"
3709                | "retrieveAndGenerate"
3710                | "retrieveAndGenerateStream"
3711                | "optimize-prompt"
3712                | "sessions"
3713                | "invocations"
3714                | "generate-query"
3715                | "rerank"
3716        )
3717    ) {
3718        return None;
3719    }
3720    let segs: Vec<&str> = path.split('/').collect();
3721    let is_runtime = matches!(
3722        segs.as_slice(),
3723        ["", "agents", _, "agentAliases", _, ..]  // InvokeAgent
3724            | ["", "flows", _, "executions"] // ListFlowExecutions
3725            | ["", "flows", _, "aliases", _, "executions", ..] // flow executions
3726            | ["", "knowledgebases", _, "retrieve"] // Retrieve
3727            | ["", "retrieveAndGenerate"]
3728            | ["", "retrieveAndGenerateStream"]
3729            | ["", "optimize-prompt"]
3730            | ["", "sessions", ..]
3731            | ["", "invocations", ..]
3732            | ["", "generate-query"]
3733            | ["", "rerank"]
3734    ) || (
3735        // InvokeFlow is POST /flows/{flow}/aliases/{alias}; the same path
3736        // under GET / PUT / DELETE is the control plane's flow-alias CRUD.
3737        *method == http::Method::POST && matches!(segs.as_slice(), ["", "flows", _, "aliases", _])
3738    ) || (
3739        // InvokeInlineAgent is POST /agents/{sessionId}; the control plane's
3740        // PrepareAgent is POST /agents/{agentId}/ (trailing slash).
3741        *method == http::Method::POST
3742            && matches!(segs.as_slice(), ["", "agents", id] if !id.is_empty())
3743    ) || path
3744        // Tagging is shared: a runtime resource (a session) is tagged through
3745        // the runtime, an agent/flow/knowledge-base resource through the
3746        // control plane.
3747        .strip_prefix("/tags/")
3748        .is_some_and(names_bedrock_session);
3749    Some(if is_runtime {
3750        "bedrock-agent-runtime"
3751    } else {
3752        "bedrock-agent"
3753    })
3754}
3755
3756#[cfg(test)]
3757mod tests {
3758
3759    fn gzip(data: &[u8]) -> Bytes {
3760        use std::io::Write;
3761        let mut enc = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
3762        enc.write_all(data).unwrap();
3763        Bytes::from(enc.finish().unwrap())
3764    }
3765
3766    fn gzip_headers(extra: &[(&'static str, &str)]) -> http::HeaderMap {
3767        let mut h = http::HeaderMap::new();
3768        h.insert("content-encoding", "gzip".parse().unwrap());
3769        for (k, v) in extra {
3770            h.insert(*k, v.parse().unwrap());
3771        }
3772        h
3773    }
3774
3775    #[test]
3776    fn request_compression_decodes_gzip_for_cloudwatch() {
3777        let body = br#"{"Namespace":"App"}"#;
3778        // awsJson (X-Amz-Target).
3779        let h = gzip_headers(&[(
3780            "x-amz-target",
3781            "GraniteServiceVersion20100801.PutMetricData",
3782        )]);
3783        assert_eq!(
3784            decode_request_compression(&h, None, gzip(body)).unwrap(),
3785            Bytes::from_static(body)
3786        );
3787        // awsQuery (SigV4 scope only).
3788        let h = gzip_headers(&[(
3789            "authorization",
3790            "AWS4-HMAC-SHA256 Credential=test/20240101/us-east-1/monitoring/aws4_request, SignedHeaders=host, Signature=0",
3791        )]);
3792        let form = b"Action=PutMetricData&Namespace=App";
3793        assert_eq!(
3794            decode_request_compression(&h, None, gzip(form)).unwrap(),
3795            Bytes::from_static(form)
3796        );
3797        // rpcv2Cbor.
3798        let detected = protocol::DetectedRequest {
3799            service: "monitoring".to_string(),
3800            action: "PutMetricData".to_string(),
3801            protocol: AwsProtocol::RpcV2Cbor,
3802        };
3803        let h = gzip_headers(&[]);
3804        assert_eq!(
3805            decode_request_compression(&h, Some(&detected), gzip(&[0xa0])).unwrap(),
3806            Bytes::from_static(&[0xa0])
3807        );
3808        // Corrupt gzip is a serialization error in the caller's protocol.
3809        let err = decode_request_compression(&h, Some(&detected), Bytes::from_static(b"nope"))
3810            .unwrap_err();
3811        assert_eq!(err.1, AwsProtocol::RpcV2Cbor);
3812    }
3813
3814    #[test]
3815    fn request_compression_leaves_other_services_alone() {
3816        // S3 keeps Content-Encoding as object metadata: the body is stored as sent.
3817        let h = gzip_headers(&[(
3818            "authorization",
3819            "AWS4-HMAC-SHA256 Credential=test/20240101/us-east-1/s3/aws4_request, SignedHeaders=host, Signature=0",
3820        )]);
3821        let body = gzip(b"object bytes");
3822        assert_eq!(
3823            decode_request_compression(&h, None, body.clone()).unwrap(),
3824            body
3825        );
3826        // No Content-Encoding: untouched.
3827        let h = http::HeaderMap::new();
3828        assert_eq!(
3829            decode_request_compression(&h, None, Bytes::from_static(b"x")).unwrap(),
3830            Bytes::from_static(b"x")
3831        );
3832    }
3833
3834    #[test]
3835    fn request_compression_services_match_the_models() {
3836        // Every vendored model with an `@requestCompression` operation must be
3837        // listed (by registry name) in REQUEST_COMPRESSION_SERVICES.
3838        let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../aws-models");
3839        let mut with_trait: Vec<String> = std::fs::read_dir(&dir)
3840            .unwrap_or_else(|e| panic!("read {}: {e}", dir.display()))
3841            .filter_map(|e| e.ok())
3842            .filter(|e| {
3843                std::fs::read_to_string(e.path())
3844                    .is_ok_and(|s| s.contains("\"smithy.api#requestCompression\""))
3845            })
3846            .map(|e| e.file_name().to_string_lossy().into_owned())
3847            .collect();
3848        with_trait.sort();
3849        assert_eq!(
3850            with_trait,
3851            vec!["cloudwatch.json".to_string()],
3852            "update REQUEST_COMPRESSION_SERVICES for new @requestCompression models"
3853        );
3854        assert_eq!(REQUEST_COMPRESSION_SERVICES, &["monitoring"]);
3855    }
3856    #[test]
3857    fn query_error_namespaces_match_the_models() {
3858        // Every vendored awsQuery model's `smithy.api#xmlNamespace` is the
3859        // namespace its errors carry, by fakecloud service name (DocumentDB
3860        // and Neptune sign as `rds` but are their own services).
3861        let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../aws-models");
3862        let mut from_models = Vec::new();
3863        for entry in std::fs::read_dir(&dir).unwrap() {
3864            let path = entry.unwrap().path();
3865            let text = std::fs::read_to_string(&path).unwrap();
3866            if !text.contains("\"aws.protocols#awsQuery\"") {
3867                continue;
3868            }
3869            let model: serde_json::Value = serde_json::from_str(&text).unwrap();
3870            let shapes = model["shapes"].as_object().unwrap();
3871            let service = shapes.values().find(|s| s["type"] == "service").unwrap();
3872            let traits = &service["traits"];
3873            if traits.get("aws.protocols#awsQuery").is_none() {
3874                continue;
3875            }
3876            let stem = path.file_stem().unwrap().to_str().unwrap().to_string();
3877            let name = if stem == "docdb" || stem == "neptune" {
3878                stem
3879            } else {
3880                traits["aws.auth#sigv4"]["name"]
3881                    .as_str()
3882                    .unwrap()
3883                    .to_string()
3884            };
3885            let ns = traits["smithy.api#xmlNamespace"]["uri"]
3886                .as_str()
3887                .unwrap()
3888                .to_string();
3889            from_models.push((name, ns));
3890        }
3891        assert!(from_models.len() >= 10, "{from_models:?}");
3892        for (name, ns) in from_models {
3893            assert_eq!(
3894                fakecloud_aws::error::QueryErrorShape::for_service(&name).namespace,
3895                Some(ns.as_str()),
3896                "{name}: update QUERY_XML_NAMESPACES"
3897            );
3898        }
3899    }
3900
3901    #[tokio::test]
3902    async fn query_errors_carry_the_service_namespace() {
3903        let detected = protocol::DetectedRequest {
3904            service: "sqs".to_string(),
3905            action: "ListQueues".to_string(),
3906            protocol: AwsProtocol::Query,
3907        };
3908        let resp = build_error_response(
3909            StatusCode::BAD_REQUEST,
3910            "InvalidParameterValue",
3911            "bad",
3912            "rid",
3913            ErrorEnvelope::for_request(&detected, &http::HeaderMap::new()),
3914        );
3915        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
3916            .await
3917            .unwrap();
3918        let body = String::from_utf8(body.to_vec()).unwrap();
3919        assert!(
3920            body.contains(r#"<ErrorResponse xmlns="http://queue.amazonaws.com/doc/2012-11-05/">"#),
3921            "{body}"
3922        );
3923        assert!(body.contains("<Detail/>"), "{body}");
3924    }
3925
3926    #[test]
3927    fn bedrock_agent_paths_split_between_runtime_and_control_plane() {
3928        use http::Method;
3929        let runtime = Some("bedrock-agent-runtime");
3930        let agent = Some("bedrock-agent");
3931        for (method, path, want) in [
3932            (Method::POST, "/flows/F/aliases/A", runtime),
3933            (Method::GET, "/flows/F/aliases/A", agent),
3934            (Method::PUT, "/flows/F/aliases/A", agent),
3935            (Method::DELETE, "/flows/F/aliases/A", agent),
3936            (Method::POST, "/flows/F/aliases/A/executions", runtime),
3937            (Method::GET, "/flows/F/aliases/A/executions/E", runtime),
3938            (
3939                Method::POST,
3940                "/flows/F/aliases/A/executions/E/stop",
3941                runtime,
3942            ),
3943            (
3944                Method::GET,
3945                "/flows/F/aliases/A/executions/E/events",
3946                runtime,
3947            ),
3948            (
3949                Method::GET,
3950                "/flows/F/aliases/A/executions/E/flowsnapshot",
3951                runtime,
3952            ),
3953            (Method::GET, "/flows/F/executions", runtime),
3954            (Method::GET, "/flows/F/aliases", agent),
3955            (Method::POST, "/flows/F/versions", agent),
3956            (Method::GET, "/flows/F", agent),
3957            (
3958                Method::POST,
3959                "/agents/X/agentAliases/Y/sessions/S/text",
3960                runtime,
3961            ),
3962            (Method::GET, "/agents/X", agent),
3963            (Method::POST, "/agents/session-1", runtime),
3964            (Method::POST, "/agents/X/", agent),
3965            (Method::GET, "/agents/X/", agent),
3966            (Method::PUT, "/agents/X/", agent),
3967            (
3968                Method::POST,
3969                "/tags/arn%3Aaws%3Abedrock%3Aus-east-1%3A123456789012%3Asession%2F0f1e2d3c-4b5a-6978-8a9b-0c1d2e3f4a5b",
3970                runtime,
3971            ),
3972            (
3973                Method::GET,
3974                "/tags/arn:aws:bedrock:us-east-1:123456789012:session/0f1e2d3c-4b5a-6978-8a9b-0c1d2e3f4a5b",
3975                runtime,
3976            ),
3977            (
3978                Method::DELETE,
3979                "/tags/arn%3aaws%3abedrock%3aus-east-1%3a123456789012%3asession%2fabc",
3980                runtime,
3981            ),
3982            (
3983                Method::POST,
3984                "/tags/arn%3Aaws%3Abedrock%3Aus-east-1%3A123456789012%3Aagent%2FAGENT12345",
3985                agent,
3986            ),
3987            (
3988                Method::GET,
3989                "/tags/arn%3Aaws%3Abedrock%3Aus-east-1%3A123456789012%3Aflow%2FFLOW123456",
3990                agent,
3991            ),
3992            (Method::POST, "/model/m/invoke", None),
3993        ] {
3994            assert_eq!(
3995                bedrock_agent_service_for(&method, path),
3996                want,
3997                "{method} {path}"
3998            );
3999        }
4000    }
4001
4002    use super::*;
4003
4004    #[test]
4005    fn default_max_request_body_bytes_is_one_gib() {
4006        // Without the env override, the cap defaults to 1 GiB. The
4007        // public function caches via OnceLock so only the first call
4008        // in the process matters; we assert the constant directly.
4009        assert_eq!(DEFAULT_MAX_REQUEST_BODY_BYTES, 1024 * 1024 * 1024);
4010    }
4011
4012    #[test]
4013    fn sigv2_presigned_access_key_extracted_with_signature_and_expires() {
4014        let mut q = HashMap::new();
4015        q.insert("AWSAccessKeyId".to_string(), "AKIAEXAMPLE".to_string());
4016        q.insert("Signature".to_string(), "abc%2Bdef".to_string());
4017        q.insert("Expires".to_string(), "1700000000".to_string());
4018        assert_eq!(
4019            sigv2_presigned_access_key(&q).as_deref(),
4020            Some("AKIAEXAMPLE")
4021        );
4022    }
4023
4024    #[test]
4025    fn sigv2_presigned_access_key_none_without_signature_or_expires() {
4026        // AWSAccessKeyId alone (e.g. a stray query param) is not a SigV2
4027        // presign and must not be treated as a credential.
4028        let mut q = HashMap::new();
4029        q.insert("AWSAccessKeyId".to_string(), "AKIAEXAMPLE".to_string());
4030        assert_eq!(sigv2_presigned_access_key(&q), None);
4031
4032        q.insert("Expires".to_string(), "1700000000".to_string());
4033        assert_eq!(
4034            sigv2_presigned_access_key(&q),
4035            None,
4036            "missing Signature must not qualify"
4037        );
4038    }
4039
4040    #[test]
4041    fn sigv2_query_auth_names_its_key() {
4042        let q = |pairs: &[(&str, &str)]| -> HashMap<String, String> {
4043            pairs
4044                .iter()
4045                .map(|(k, v)| (k.to_string(), v.to_string()))
4046                .collect()
4047        };
4048        let signed = q(&[
4049            ("Action", "ListQueues"),
4050            ("AWSAccessKeyId", "AKIDSIGV2"),
4051            ("Signature", "abc"),
4052            ("SignatureVersion", "2"),
4053            ("SignatureMethod", "HmacSHA256"),
4054            ("Timestamp", "2026-11-13T00:00:00Z"),
4055        ]);
4056        assert_eq!(
4057            sigv2_query_access_key(&signed).as_deref(),
4058            Some("AKIDSIGV2")
4059        );
4060        assert!(!presents_no_credential(&http::HeaderMap::new(), &signed));
4061        // Without SignatureVersion=2 (or a Signature) it is not SigV2 Query
4062        // authentication.
4063        assert_eq!(
4064            sigv2_query_access_key(&q(&[("AWSAccessKeyId", "A"), ("Signature", "s")])),
4065            None
4066        );
4067        assert_eq!(
4068            sigv2_query_access_key(&q(&[("AWSAccessKeyId", "A"), ("SignatureVersion", "2")])),
4069            None
4070        );
4071    }
4072
4073    #[test]
4074    fn sigv2_presigned_access_key_none_for_unsigned_request() {
4075        assert_eq!(sigv2_presigned_access_key(&HashMap::new()), None);
4076    }
4077
4078    #[test]
4079    fn is_hex_sha256_accepts_real_digest_rejects_markers() {
4080        // A genuine 64-char lowercase-hex digest is a bindable body hash.
4081        assert!(is_hex_sha256(&sha256_hex_lower(b"hello")));
4082        assert!(is_hex_sha256(
4083            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
4084        ));
4085        // The SigV4 payload markers are NOT hashes and must be skipped.
4086        assert!(!is_hex_sha256("UNSIGNED-PAYLOAD"));
4087        assert!(!is_hex_sha256("STREAMING-AWS4-HMAC-SHA256-PAYLOAD"));
4088        assert!(!is_hex_sha256("STREAMING-UNSIGNED-PAYLOAD-TRAILER"));
4089        // Wrong length or non-lowercase-hex characters are rejected.
4090        assert!(!is_hex_sha256("abc123"));
4091        assert!(!is_hex_sha256(
4092            "E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855"
4093        ));
4094    }
4095
4096    #[test]
4097    fn sha256_hex_lower_matches_known_vectors() {
4098        // Empty input -> the well-known SHA-256 of "".
4099        assert_eq!(
4100            sha256_hex_lower(b""),
4101            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
4102        );
4103        assert_eq!(
4104            sha256_hex_lower(b"abc"),
4105            "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
4106        );
4107        assert_eq!(sha256_hex_lower(b"abc").len(), 64);
4108    }
4109
4110    #[test]
4111    fn global_request_keys_cover_resource_account_and_org() {
4112        struct Org;
4113        impl crate::auth::ScpResolver for Org {
4114            fn scps_for(&self, _: &Principal) -> Option<Vec<String>> {
4115                None
4116            }
4117            fn principal_org(&self, account: &str) -> Option<(String, String)> {
4118                (account == "111111111111")
4119                    .then(|| ("o-abc".to_string(), "o-abc/r-1/ou-2/".to_string()))
4120            }
4121        }
4122        let principal = |account: &str| Principal {
4123            arn: format!("arn:aws:iam::{account}:user/u"),
4124            user_id: "AIDAU".into(),
4125            account_id: account.into(),
4126            principal_type: PrincipalType::User,
4127            source_identity: None,
4128            tags: None,
4129        };
4130        let mut ctx = ConditionContext::default();
4131        add_global_request_keys(
4132            &mut ctx,
4133            &principal("111111111111"),
4134            "222222222222",
4135            Some(&Org),
4136        );
4137        assert_eq!(
4138            ctx.lookup("aws:ResourceAccount"),
4139            Some(vec!["222222222222".into()])
4140        );
4141        assert_eq!(ctx.lookup("aws:PrincipalOrgID"), Some(vec!["o-abc".into()]));
4142        assert_eq!(
4143            ctx.lookup("aws:PrincipalOrgPaths"),
4144            Some(vec!["o-abc/r-1/ou-2/".into()])
4145        );
4146        // A principal in no organization gets no org keys (AWS omits them).
4147        let mut ctx = ConditionContext::default();
4148        add_global_request_keys(
4149            &mut ctx,
4150            &principal("333333333333"),
4151            "333333333333",
4152            Some(&Org),
4153        );
4154        assert_eq!(ctx.lookup("aws:PrincipalOrgID"), None);
4155        assert_eq!(
4156            ctx.lookup("aws:ResourceAccount"),
4157            Some(vec!["333333333333".into()])
4158        );
4159    }
4160
4161    #[test]
4162    fn unresolved_credential_distinguishes_expired_from_invalid() {
4163        struct Expired;
4164        impl CredentialResolver for Expired {
4165            fn resolve(&self, _: &str) -> Option<crate::auth::ResolvedCredential> {
4166                None
4167            }
4168            fn is_expired(&self, akid: &str) -> bool {
4169                akid == "ASIAEXPIRED"
4170            }
4171        }
4172        let mut cfg = DispatchConfig::new("us-east-1", "123456789012");
4173        cfg.credential_resolver = Some(Arc::new(Expired));
4174        let detected = protocol::DetectedRequest {
4175            service: "sts".into(),
4176            action: "GetCallerIdentity".into(),
4177            protocol: AwsProtocol::Query,
4178        };
4179        let code = |akid: &str| {
4180            let resp = unresolved_credential_response(
4181                &cfg,
4182                akid,
4183                "r",
4184                ErrorEnvelope::for_request(&detected, &http::HeaderMap::new()),
4185            );
4186            assert_eq!(resp.status(), StatusCode::FORBIDDEN);
4187            resp.headers()["x-amz-error-code"]
4188                .to_str()
4189                .unwrap()
4190                .to_string()
4191        };
4192        assert_eq!(code("ASIAEXPIRED"), "ExpiredToken");
4193        assert_eq!(code("AKIAUNKNOWN"), "InvalidClientTokenId");
4194    }
4195
4196    #[test]
4197    fn dispatch_config_new_defaults_to_off() {
4198        let cfg = DispatchConfig::new("us-east-1", "123456789012");
4199        assert_eq!(cfg.region, "us-east-1");
4200        assert_eq!(cfg.account_id, "123456789012");
4201        assert!(!cfg.verify_sigv4);
4202        assert_eq!(cfg.iam_mode, IamMode::Off);
4203    }
4204
4205    /// An evaluator whose identity-only decision is fixed, and whose ceiling
4206    /// allows unless the SCPs (any `scps` given) or the session policies
4207    /// (any given) are set to deny.
4208    struct IdentityOnly {
4209        identity: crate::auth::IamDecision,
4210        scp_allows: bool,
4211        session_allows: bool,
4212        boundary_allows: bool,
4213    }
4214
4215    impl IamPolicyEvaluator for IdentityOnly {
4216        fn evaluate(
4217            &self,
4218            _: &Principal,
4219            _: &crate::auth::IamAction,
4220            _: &ConditionContext,
4221            _: &[String],
4222            _: Option<&[String]>,
4223        ) -> crate::auth::IamDecision {
4224            self.identity
4225        }
4226
4227        fn evaluate_ceiling(
4228            &self,
4229            _: &Principal,
4230            _: &crate::auth::IamAction,
4231            _: &ConditionContext,
4232            session_policies: &[String],
4233            scps: Option<&[String]>,
4234            with_boundary: bool,
4235        ) -> crate::auth::IamDecision {
4236            let scp_ok = scps.is_none() || self.scp_allows;
4237            let session_ok = session_policies.is_empty() || self.session_allows;
4238            let boundary_ok = !with_boundary || self.boundary_allows;
4239            if scp_ok && session_ok && boundary_ok {
4240                crate::auth::IamDecision::Allow
4241            } else {
4242                crate::auth::IamDecision::ImplicitDeny
4243            }
4244        }
4245
4246        fn evaluate_with_resource_policy(
4247            &self,
4248            _: &Principal,
4249            _: &crate::auth::IamAction,
4250            _: &ConditionContext,
4251            _: Option<&str>,
4252            _: &str,
4253            _: &[String],
4254            _: Option<&[String]>,
4255        ) -> crate::auth::IamDecision {
4256            crate::auth::IamDecision::ImplicitDeny
4257        }
4258    }
4259
4260    /// A provider whose grants match every request the same way.
4261    struct Grants(Option<crate::auth::GrantAuthorization>);
4262
4263    impl ResourcePolicyProvider for Grants {
4264        fn resource_policy(&self, _: &str, _: &str) -> Option<String> {
4265            None
4266        }
4267
4268        fn grant_authorization(
4269            &self,
4270            _: &str,
4271            _: &AwsRequest,
4272            _: &crate::auth::IamAction,
4273            _: &Principal,
4274        ) -> Option<crate::auth::GrantAuthorization> {
4275            self.0
4276        }
4277    }
4278
4279    /// A grant naming the caller authorizes it on its own; one naming its
4280    /// account needs the caller's identity policies to allow the action too.
4281    #[test]
4282    fn grants_authorize_their_grantee_and_delegate_to_its_account() {
4283        use crate::auth::{GrantAuthorization, IamDecision};
4284        let request = AwsRequest {
4285            service: "kms".into(),
4286            action: "Decrypt".into(),
4287            region: "us-east-1".into(),
4288            account_id: "222222222222".into(),
4289            request_id: "rid".into(),
4290            headers: http::HeaderMap::new(),
4291            query_params: HashMap::new(),
4292            body: Bytes::new(),
4293            body_stream: parking_lot::Mutex::new(None),
4294            path_segments: Vec::new(),
4295            raw_path: "/".into(),
4296            raw_query: String::new(),
4297            method: http::Method::POST,
4298            is_query_protocol: false,
4299            access_key_id: None,
4300            principal: None,
4301        };
4302        let action = crate::auth::IamAction {
4303            service: "kms",
4304            action: "Decrypt",
4305            resource: "arn:aws:kms:us-east-1:111111111111:key/k".into(),
4306        };
4307        let principal = Principal {
4308            arn: "arn:aws:iam::222222222222:user/u".into(),
4309            user_id: "AIDA".into(),
4310            account_id: "222222222222".into(),
4311            principal_type: PrincipalType::User,
4312            source_identity: None,
4313            tags: None,
4314        };
4315        let session = vec!["{}".to_string()];
4316        let scps = vec!["{}".to_string()];
4317        let check = |grant: Option<GrantAuthorization>,
4318                     evaluator: IdentityOnly,
4319                     provided: bool,
4320                     session_policies: &[String],
4321                     scps: Option<&[String]>| {
4322            let provider = Grants(grant);
4323            grant_authorizes(
4324                provided.then_some(&provider as &dyn ResourcePolicyProvider),
4325                &evaluator,
4326                "kms",
4327                &request,
4328                &action,
4329                &principal,
4330                &ConditionContext::default(),
4331                session_policies,
4332                scps,
4333            )
4334        };
4335        let open = |identity| IdentityOnly {
4336            identity,
4337            scp_allows: true,
4338            session_allows: true,
4339            boundary_allows: true,
4340        };
4341        let authorizes =
4342            |grant, identity, provided| check(grant, open(identity), provided, &[], None);
4343        let grantee = Some(GrantAuthorization::Grantee);
4344        let role = Some(GrantAuthorization::GranteeRole);
4345        let account = Some(GrantAuthorization::Account);
4346        assert!(authorizes(grantee, IamDecision::ImplicitDeny, true));
4347        assert!(authorizes(role, IamDecision::ImplicitDeny, true));
4348        assert!(authorizes(account, IamDecision::Allow, true));
4349        assert!(!authorizes(account, IamDecision::ImplicitDeny, true));
4350        assert!(!authorizes(None, IamDecision::Allow, true));
4351        assert!(!authorizes(grantee, IamDecision::Allow, false));
4352
4353        // An SCP that does not allow the action caps every grant match.
4354        let scp_denies = || IdentityOnly {
4355            identity: IamDecision::ImplicitDeny,
4356            scp_allows: false,
4357            session_allows: true,
4358            boundary_allows: true,
4359        };
4360        for grant in [grantee, role] {
4361            assert!(!check(grant, scp_denies(), true, &[], Some(&scps)));
4362        }
4363        // Session policies cap a grant to the session's role, not one naming
4364        // the session itself.
4365        let session_denies = || IdentityOnly {
4366            identity: IamDecision::ImplicitDeny,
4367            scp_allows: true,
4368            session_allows: false,
4369            boundary_allows: true,
4370        };
4371        assert!(!check(role, session_denies(), true, &session, None));
4372        assert!(check(grantee, session_denies(), true, &session, None));
4373        // So does the role's permissions boundary; a grant naming the user
4374        // or session itself is not capped by a boundary.
4375        let boundary_denies = || IdentityOnly {
4376            identity: IamDecision::ImplicitDeny,
4377            scp_allows: true,
4378            session_allows: true,
4379            boundary_allows: false,
4380        };
4381        assert!(!check(role, boundary_denies(), true, &[], None));
4382        assert!(check(grantee, boundary_denies(), true, &[], None));
4383    }
4384
4385    #[test]
4386    fn aws_username_strips_iam_path_for_users() {
4387        let p = Principal {
4388            arn: "arn:aws:iam::123456789012:user/engineering/alice".into(),
4389            user_id: "AIDAALICE".into(),
4390            account_id: "123456789012".into(),
4391            principal_type: PrincipalType::User,
4392            source_identity: None,
4393            tags: None,
4394        };
4395        assert_eq!(aws_username_from_principal(&p), Some("alice".into()));
4396    }
4397
4398    #[test]
4399    fn aws_username_unset_for_assumed_role() {
4400        let p = Principal {
4401            arn: "arn:aws:sts::123456789012:assumed-role/ops/session".into(),
4402            user_id: "AROAOPS:session".into(),
4403            account_id: "123456789012".into(),
4404            principal_type: PrincipalType::AssumedRole,
4405            source_identity: None,
4406            tags: None,
4407        };
4408        assert_eq!(aws_username_from_principal(&p), None);
4409    }
4410
4411    #[test]
4412    fn principal_type_label_matches_aws_casing() {
4413        assert_eq!(principal_type_label(PrincipalType::User), "User");
4414        assert_eq!(
4415            principal_type_label(PrincipalType::AssumedRole),
4416            "AssumedRole"
4417        );
4418        assert_eq!(principal_type_label(PrincipalType::Root), "Account");
4419    }
4420
4421    #[test]
4422    fn build_condition_context_populates_global_keys() {
4423        let p = Principal {
4424            arn: "arn:aws:iam::123456789012:user/alice".into(),
4425            user_id: "AIDAALICE".into(),
4426            account_id: "123456789012".into(),
4427            principal_type: PrincipalType::User,
4428            source_identity: None,
4429            tags: None,
4430        };
4431        let addr: SocketAddr = "10.0.0.1:54321".parse().unwrap();
4432        let ctx = build_condition_context(&p, Some(addr), "us-east-1", false);
4433        assert_eq!(ctx.aws_username.as_deref(), Some("alice"));
4434        assert_eq!(ctx.aws_userid.as_deref(), Some("AIDAALICE"));
4435        assert_eq!(
4436            ctx.aws_principal_arn.as_deref(),
4437            Some("arn:aws:iam::123456789012:user/alice")
4438        );
4439        assert_eq!(ctx.aws_principal_account.as_deref(), Some("123456789012"));
4440        assert_eq!(ctx.aws_principal_type.as_deref(), Some("User"));
4441        assert_eq!(
4442            ctx.aws_source_ip.map(|i| i.to_string()).as_deref(),
4443            Some("10.0.0.1")
4444        );
4445        assert_eq!(ctx.aws_requested_region.as_deref(), Some("us-east-1"));
4446        assert_eq!(ctx.aws_secure_transport, Some(false));
4447        assert!(ctx.aws_current_time.is_some());
4448        assert!(ctx.aws_epoch_time.is_some());
4449    }
4450
4451    #[test]
4452    fn is_secure_transport_reads_x_forwarded_proto() {
4453        let mut headers = http::HeaderMap::new();
4454        headers.insert("x-forwarded-proto", "https".parse().unwrap());
4455        assert!(is_secure_transport(&headers));
4456        headers.insert("x-forwarded-proto", "http".parse().unwrap());
4457        assert!(!is_secure_transport(&headers));
4458        let empty = http::HeaderMap::new();
4459        assert!(!is_secure_transport(&empty));
4460    }
4461
4462    #[test]
4463    fn parse_account_from_arn_extracts_standard_shapes() {
4464        assert_eq!(
4465            parse_account_from_arn("arn:aws:sqs:us-east-1:123456789012:queue"),
4466            Some("123456789012".to_string())
4467        );
4468        assert_eq!(
4469            parse_account_from_arn("arn:aws:iam::123456789012:user/alice"),
4470            Some("123456789012".to_string())
4471        );
4472    }
4473
4474    #[test]
4475    fn parse_account_from_arn_returns_none_for_s3_empty_account() {
4476        // S3 ARNs have both region and account empty.
4477        assert_eq!(parse_account_from_arn("arn:aws:s3:::my-bucket"), None);
4478        assert_eq!(
4479            parse_account_from_arn("arn:aws:s3:::my-bucket/path/to/key"),
4480            None
4481        );
4482    }
4483
4484    #[test]
4485    fn parse_account_from_arn_returns_none_for_malformed() {
4486        assert_eq!(parse_account_from_arn(""), None);
4487        assert_eq!(parse_account_from_arn("not-an-arn"), None);
4488        assert_eq!(parse_account_from_arn("arn:aws:sqs:us-east-1"), None);
4489        assert_eq!(parse_account_from_arn("arn:aws:sqs"), None);
4490    }
4491
4492    #[test]
4493    fn extract_region_from_user_agent_finds_region_segment() {
4494        let mut headers = http::HeaderMap::new();
4495        headers.insert(
4496            "user-agent",
4497            "aws-sdk-rust/1.0 os/linux region/eu-central-1"
4498                .parse()
4499                .unwrap(),
4500        );
4501        assert_eq!(
4502            extract_region_from_user_agent(&headers),
4503            Some("eu-central-1".to_string())
4504        );
4505    }
4506
4507    #[test]
4508    fn extract_region_from_user_agent_none_without_header() {
4509        let headers = http::HeaderMap::new();
4510        assert_eq!(extract_region_from_user_agent(&headers), None);
4511    }
4512
4513    #[test]
4514    fn extract_region_from_user_agent_ignores_empty_region() {
4515        let mut headers = http::HeaderMap::new();
4516        headers.insert("user-agent", "aws-sdk-java region/".parse().unwrap());
4517        assert_eq!(extract_region_from_user_agent(&headers), None);
4518    }
4519
4520    #[test]
4521    fn extract_region_from_user_agent_none_when_no_region_marker() {
4522        let mut headers = http::HeaderMap::new();
4523        headers.insert("user-agent", "curl/7.79.1".parse().unwrap());
4524        assert_eq!(extract_region_from_user_agent(&headers), None);
4525    }
4526
4527    #[test]
4528    fn aws_username_none_for_root() {
4529        let p = Principal {
4530            arn: "arn:aws:iam::123456789012:root".into(),
4531            user_id: "123456789012".into(),
4532            account_id: "123456789012".into(),
4533            principal_type: PrincipalType::Root,
4534            source_identity: None,
4535            tags: None,
4536        };
4537        assert_eq!(aws_username_from_principal(&p), None);
4538    }
4539
4540    #[test]
4541    fn aws_username_bare_no_path() {
4542        let p = Principal {
4543            arn: "arn:aws:iam::123456789012:user/bob".into(),
4544            user_id: "AIDABOB".into(),
4545            account_id: "123456789012".into(),
4546            principal_type: PrincipalType::User,
4547            source_identity: None,
4548            tags: None,
4549        };
4550        assert_eq!(aws_username_from_principal(&p), Some("bob".into()));
4551    }
4552
4553    #[test]
4554    fn principal_type_label_covers_federated_and_unknown() {
4555        assert_eq!(
4556            principal_type_label(PrincipalType::FederatedUser),
4557            "FederatedUser"
4558        );
4559        assert_eq!(principal_type_label(PrincipalType::Unknown), "Unknown");
4560    }
4561
4562    #[test]
4563    fn build_condition_context_marks_secure_when_flag_set() {
4564        let p = Principal {
4565            arn: "arn:aws:iam::123456789012:user/alice".into(),
4566            user_id: "AIDAALICE".into(),
4567            account_id: "123456789012".into(),
4568            principal_type: PrincipalType::User,
4569            source_identity: None,
4570            tags: None,
4571        };
4572        let ctx = build_condition_context(&p, None, "us-west-2", true);
4573        assert_eq!(ctx.aws_secure_transport, Some(true));
4574        assert!(ctx.aws_source_ip.is_none());
4575        assert_eq!(ctx.aws_requested_region.as_deref(), Some("us-west-2"));
4576    }
4577
4578    #[test]
4579    fn is_secure_transport_case_insensitive() {
4580        let mut headers = http::HeaderMap::new();
4581        headers.insert("x-forwarded-proto", "HTTPS".parse().unwrap());
4582        assert!(is_secure_transport(&headers));
4583    }
4584
4585    #[test]
4586    fn is_secure_transport_non_ascii_bytes_false() {
4587        let mut headers = http::HeaderMap::new();
4588        headers.insert(
4589            "x-forwarded-proto",
4590            http::HeaderValue::from_bytes(&[0xFF, 0xFE]).unwrap(),
4591        );
4592        assert!(!is_secure_transport(&headers));
4593    }
4594
4595    #[test]
4596    fn protocol_ext_error_status_is_bad_request() {
4597        assert_eq!(AwsProtocol::Query.error_status(), StatusCode::BAD_REQUEST);
4598        assert_eq!(AwsProtocol::Json.error_status(), StatusCode::BAD_REQUEST);
4599        assert_eq!(AwsProtocol::Rest.error_status(), StatusCode::BAD_REQUEST);
4600        assert_eq!(
4601            AwsProtocol::RestJson.error_status(),
4602            StatusCode::BAD_REQUEST
4603        );
4604    }
4605
4606    #[test]
4607    fn build_error_response_json_has_json_content_type() {
4608        let resp = build_error_response(
4609            StatusCode::BAD_REQUEST,
4610            "TestCode",
4611            "test msg",
4612            "req-1",
4613            AwsProtocol::Json,
4614        );
4615        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
4616        let ct = resp
4617            .headers()
4618            .get("content-type")
4619            .unwrap()
4620            .to_str()
4621            .unwrap();
4622        assert!(ct.contains("json"));
4623        let rid = resp
4624            .headers()
4625            .get("x-amzn-requestid")
4626            .unwrap()
4627            .to_str()
4628            .unwrap();
4629        assert_eq!(rid, "req-1");
4630    }
4631
4632    #[test]
4633    fn build_error_response_rest_returns_xml_content_type() {
4634        let resp = build_error_response(
4635            StatusCode::NOT_FOUND,
4636            "NoSuchBucket",
4637            "bucket missing",
4638            "req-2",
4639            AwsProtocol::Rest,
4640        );
4641        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
4642        let ct = resp
4643            .headers()
4644            .get("content-type")
4645            .unwrap()
4646            .to_str()
4647            .unwrap();
4648        assert!(ct.contains("xml"));
4649    }
4650
4651    /// Lambda answers an error with its code in `x-amzn-ErrorType` and a
4652    /// body shaped by the error's Smithy members, as recorded; other
4653    /// restJson1 services keep their body but get the header too.
4654    #[tokio::test]
4655    async fn lambda_errors_carry_x_amzn_errortype_and_lambdas_body() {
4656        let detected = protocol::DetectedRequest {
4657            service: "lambda".to_string(),
4658            action: String::new(),
4659            protocol: AwsProtocol::RestJson,
4660        };
4661        let resp = build_error_response(
4662            StatusCode::NOT_FOUND,
4663            "ResourceNotFoundException",
4664            "Function not found: f",
4665            "req-3",
4666            ErrorEnvelope::for_request(&detected, &http::HeaderMap::new()),
4667        );
4668        assert_eq!(
4669            resp.headers()["x-amzn-errortype"],
4670            "ResourceNotFoundException"
4671        );
4672        assert_eq!(resp.headers()["content-type"], "application/json");
4673        let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
4674            .await
4675            .unwrap();
4676        let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
4677        assert_eq!(
4678            body,
4679            serde_json::json!({"Type": "User", "Message": "Function not found: f"})
4680        );
4681
4682        let other = protocol::DetectedRequest {
4683            service: "apigateway".to_string(),
4684            action: String::new(),
4685            protocol: AwsProtocol::RestJson,
4686        };
4687        let resp = build_error_response(
4688            StatusCode::NOT_FOUND,
4689            "NotFoundException",
4690            "Invalid API identifier specified",
4691            "req-4",
4692            ErrorEnvelope::for_request(&other, &http::HeaderMap::new()),
4693        );
4694        assert_eq!(resp.headers()["x-amzn-errortype"], "NotFoundException");
4695        let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
4696            .await
4697            .unwrap();
4698        let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
4699        assert_eq!(body["__type"], "NotFoundException");
4700    }
4701
4702    #[test]
4703    fn lambda_refuses_an_unknown_access_key_as_unrecognized_client() {
4704        let lambda = protocol::DetectedRequest {
4705            service: "lambda".to_string(),
4706            action: String::new(),
4707            protocol: AwsProtocol::RestJson,
4708        };
4709        let envelope = ErrorEnvelope::for_request(&lambda, &http::HeaderMap::new());
4710        assert_eq!(
4711            invalid_token_error(&envelope),
4712            (
4713                "UnrecognizedClientException",
4714                "The security token included in the request is invalid."
4715            )
4716        );
4717        assert_eq!(
4718            invalid_token_error(&ErrorEnvelope::from(AwsProtocol::Query)).0,
4719            "InvalidClientTokenId"
4720        );
4721    }
4722
4723    fn rest_detected(service: &str) -> protocol::DetectedRequest {
4724        protocol::DetectedRequest {
4725            service: service.to_string(),
4726            action: String::new(),
4727            protocol: AwsProtocol::Rest,
4728        }
4729    }
4730
4731    async fn body_string(resp: Response<Body>) -> String {
4732        let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
4733            .await
4734            .unwrap();
4735        String::from_utf8(bytes.to_vec()).unwrap()
4736    }
4737
4738    #[tokio::test]
4739    async fn cloudfront_and_route53_errors_use_error_response_wrapper() {
4740        for (service, ns) in [
4741            (
4742                "cloudfront",
4743                "http://cloudfront.amazonaws.com/doc/2020-05-31/",
4744            ),
4745            ("route53", "https://route53.amazonaws.com/doc/2013-04-01/"),
4746        ] {
4747            let resp = build_error_response(
4748                StatusCode::NOT_FOUND,
4749                "NoSuchThing",
4750                "missing",
4751                "req-w",
4752                ErrorEnvelope::for_request(&rest_detected(service), &http::HeaderMap::new()),
4753            );
4754            assert_eq!(resp.status(), StatusCode::NOT_FOUND);
4755            assert_eq!(
4756                resp.headers().get("x-amz-error-code").unwrap(),
4757                "NoSuchThing"
4758            );
4759            let body = body_string(resp).await;
4760            assert!(
4761                body.contains(&format!(
4762                    "<ErrorResponse xmlns=\"{ns}\"><Error><Type>Sender</Type>\
4763                     <Code>NoSuchThing</Code><Message>missing</Message></Error>\
4764                     <RequestId>req-w</RequestId></ErrorResponse>"
4765                )),
4766                "{service}: {body}"
4767            );
4768        }
4769    }
4770
4771    #[tokio::test]
4772    async fn s3_errors_keep_bare_error_document() {
4773        let resp = build_error_response(
4774            StatusCode::NOT_FOUND,
4775            "NoSuchBucket",
4776            "missing",
4777            "req-s3",
4778            ErrorEnvelope::for_request(&rest_detected("s3"), &http::HeaderMap::new()),
4779        );
4780        let body = body_string(resp).await;
4781        assert!(!body.contains("<ErrorResponse"), "{body}");
4782        assert!(body.contains("<Error>"), "{body}");
4783        assert!(body.contains("<Code>NoSuchBucket</Code>"), "{body}");
4784    }
4785
4786    #[tokio::test]
4787    async fn s3_control_errors_use_error_response_wrapper() {
4788        let mut headers = http::HeaderMap::new();
4789        headers.insert(
4790            "host",
4791            "000000000000.s3-control.us-east-1.amazonaws.com"
4792                .parse()
4793                .unwrap(),
4794        );
4795        let resp = build_error_response(
4796            StatusCode::NOT_FOUND,
4797            "NoSuchAccessPoint",
4798            "missing",
4799            "req-ctl",
4800            ErrorEnvelope::for_request(&rest_detected("s3"), &headers),
4801        );
4802        assert_eq!(
4803            resp.headers().get("x-amz-error-code").unwrap(),
4804            "NoSuchAccessPoint"
4805        );
4806        let body = body_string(resp).await;
4807        assert!(
4808            body.contains(
4809                "<ErrorResponse><Error><Code>NoSuchAccessPoint</Code>\
4810                 <Message>missing</Message></Error>\
4811                 <RequestId>req-ctl</RequestId></ErrorResponse>"
4812            ),
4813            "{body}"
4814        );
4815    }
4816
4817    #[test]
4818    fn build_error_response_query_returns_xml() {
4819        let resp = build_error_response(
4820            StatusCode::BAD_REQUEST,
4821            "InvalidParameter",
4822            "bad param",
4823            "req-3",
4824            AwsProtocol::Query,
4825        );
4826        let ct = resp
4827            .headers()
4828            .get("content-type")
4829            .unwrap()
4830            .to_str()
4831            .unwrap();
4832        assert!(ct.contains("xml"));
4833    }
4834
4835    /// Regression for issue #1539: multi-line backend errors (e.g. podman
4836    /// stderr) used to panic the dispatcher when stuffed into the
4837    /// `x-amz-error-message` HTTP header. The response must build cleanly
4838    /// and the header value must not contain control characters.
4839    #[test]
4840    fn build_error_response_with_multiline_message_does_not_panic() {
4841        let resp = build_error_response(
4842            StatusCode::INTERNAL_SERVER_ERROR,
4843            "ServiceException",
4844            "Lambda execution failed: container failed to start: docker start failed: \
4845             Error: unable to start container \"abc\": \
4846             failed to create new hosts file:\nhost-gateway is empty\n",
4847            "req-multi",
4848            AwsProtocol::Json,
4849        );
4850        assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
4851        let msg = resp
4852            .headers()
4853            .get("x-amz-error-message")
4854            .expect("x-amz-error-message must be set even when input contains newlines")
4855            .to_str()
4856            .unwrap();
4857        assert!(!msg.contains('\n'));
4858        assert!(!msg.contains('\r'));
4859        assert!(msg.contains("Lambda execution failed"));
4860        assert!(msg.contains("host-gateway is empty"));
4861    }
4862
4863    #[test]
4864    fn build_error_response_with_control_chars_strips_them() {
4865        let resp = build_error_response(
4866            StatusCode::BAD_REQUEST,
4867            "Code\twith\ttabs",
4868            "msg\x00with\x01nulls",
4869            "req-ctrl",
4870            AwsProtocol::Json,
4871        );
4872        let code = resp
4873            .headers()
4874            .get("x-amz-error-code")
4875            .unwrap()
4876            .to_str()
4877            .unwrap();
4878        let msg = resp
4879            .headers()
4880            .get("x-amz-error-message")
4881            .unwrap()
4882            .to_str()
4883            .unwrap();
4884        assert!(!code.contains('\t'));
4885        assert!(!msg.contains('\x00'));
4886        assert!(!msg.contains('\x01'));
4887    }
4888
4889    #[test]
4890    fn sanitize_header_value_truncates_long_input() {
4891        let huge = "x".repeat(5_000);
4892        let out = sanitize_header_value(&huge);
4893        assert!(out.len() <= 1024);
4894    }
4895
4896    #[test]
4897    fn sanitize_header_value_collapses_consecutive_control_runs() {
4898        let out = sanitize_header_value("a\n\n\n\rb");
4899        assert_eq!(out, "a b");
4900    }
4901
4902    #[test]
4903    fn anonymous_s3_probe_finds_a_bucket_on_a_china_server() {
4904        // Answers only for ARNs the S3 policy provider's bucket parser reads
4905        // (`arn:aws:s3:::<bucket>`), so a probe the real provider would not
4906        // understand misses here too.
4907        struct RecordingProvider(parking_lot::Mutex<Vec<String>>);
4908        impl crate::auth::ResourcePolicyProvider for RecordingProvider {
4909            fn resource_policy(&self, _service: &str, _resource_arn: &str) -> Option<String> {
4910                None
4911            }
4912            fn resource_owner_account(&self, _service: &str, resource_arn: &str) -> Option<String> {
4913                self.0.lock().push(resource_arn.to_string());
4914                resource_arn
4915                    .strip_prefix("arn:aws:s3:::")
4916                    .filter(|bucket| *bucket == "my-bucket")
4917                    .map(|_| "000000000000".to_string())
4918            }
4919        }
4920        let provider = Arc::new(RecordingProvider(parking_lot::Mutex::new(Vec::new())));
4921        let mut cfg = DispatchConfig::new("cn-north-1", "000000000000");
4922        cfg.resource_policy_provider = Some(provider.clone());
4923        let uri: http::Uri = "/my-bucket/key.txt".parse().unwrap();
4924        assert_eq!(
4925            anonymous_s3_bucket(&uri, &cfg),
4926            Some("my-bucket".to_string())
4927        );
4928        assert_eq!(
4929            *provider.0.lock(),
4930            vec!["arn:aws:s3:::my-bucket".to_string()]
4931        );
4932    }
4933
4934    /// A registry with an `s3` service and an `apigateway` one that claims an
4935    /// execute-api `Host`, and by path a first segment `prod` (a deployed
4936    /// stage).
4937    fn unsigned_routing_registry(with_s3: bool) -> ServiceRegistry {
4938        struct Named(&'static str);
4939        #[async_trait::async_trait]
4940        impl crate::service::AwsService for Named {
4941            fn service_name(&self) -> &str {
4942                self.0
4943            }
4944            async fn handle(
4945                &self,
4946                _req: AwsRequest,
4947            ) -> Result<crate::service::AwsResponse, crate::service::AwsServiceError> {
4948                unreachable!("routing only")
4949            }
4950            fn supported_actions(&self) -> &[&str] {
4951                &[]
4952            }
4953            fn claim_unrouted_request(
4954                &self,
4955                _method: &http::Method,
4956                raw_path: &str,
4957                headers: &http::HeaderMap,
4958                account_id: &str,
4959            ) -> Option<crate::service::DataPlaneClaim> {
4960                assert_eq!(account_id, "000000000000");
4961                if self.0 != "apigateway" {
4962                    None
4963                } else if is_execute_api_host(headers) {
4964                    Some(crate::service::DataPlaneClaim::Host)
4965                } else if raw_path.starts_with("/prod/") {
4966                    Some(crate::service::DataPlaneClaim::Path)
4967                } else {
4968                    None
4969                }
4970            }
4971        }
4972        let mut registry = ServiceRegistry::new();
4973        registry.register(Arc::new(Named("apigateway")));
4974        if with_s3 {
4975            registry.register(Arc::new(Named("s3")));
4976        }
4977        registry
4978    }
4979
4980    #[test]
4981    fn unsigned_path_style_requests_go_to_s3_unless_api_gateway_claims_them() {
4982        // Bucket `prod` exists only when the test says so.
4983        struct Buckets(&'static [&'static str]);
4984        impl crate::auth::ResourcePolicyProvider for Buckets {
4985            fn resource_policy(&self, _service: &str, _resource_arn: &str) -> Option<String> {
4986                None
4987            }
4988            fn resource_owner_account(&self, _service: &str, resource_arn: &str) -> Option<String> {
4989                resource_arn
4990                    .strip_prefix("arn:aws:s3:::")
4991                    .filter(|b| self.0.contains(b))
4992                    .map(|_| "000000000000".to_string())
4993            }
4994        }
4995        let registry = unsigned_routing_registry(true);
4996        let mut cfg = DispatchConfig::new("us-east-1", "000000000000");
4997        cfg.resource_policy_provider = Some(Arc::new(Buckets(&[])));
4998        let target = |cfg: &DispatchConfig,
4999                      method: http::Method,
5000                      uri: &str,
5001                      headers: &http::HeaderMap,
5002                      query: &[(&str, &str)]| {
5003            let query: HashMap<String, String> = query
5004                .iter()
5005                .map(|(k, v)| (k.to_string(), v.to_string()))
5006                .collect();
5007            unrouted_target(
5008                &method,
5009                &uri.parse().unwrap(),
5010                headers,
5011                &query,
5012                &registry,
5013                cfg,
5014            )
5015        };
5016        let is_s3 =
5017            |method: http::Method, uri: &str, headers: &http::HeaderMap, query: &[(&str, &str)]| {
5018                target(&cfg, method, uri, headers, query) == Some("s3")
5019            };
5020        let none = http::HeaderMap::new();
5021        // Missing bucket, any method, and the bare root (ListBuckets).
5022        assert!(is_s3(http::Method::GET, "/ghost-bucket/k.txt", &none, &[]));
5023        assert!(is_s3(http::Method::PUT, "/ghost-bucket/k.txt", &none, &[]));
5024        assert!(is_s3(http::Method::PUT, "/ghost-bucket", &none, &[]));
5025        assert!(is_s3(http::Method::GET, "/", &none, &[]));
5026        // A plain query string (not a presign) keeps it unsigned.
5027        assert!(is_s3(
5028            http::Method::GET,
5029            "/ghost-bucket",
5030            &none,
5031            &[("list-type", "2")]
5032        ));
5033        // API Gateway claims its invocation.
5034        assert!(!is_s3(http::Method::GET, "/prod/items", &none, &[]));
5035        // fakecloud endpoints are never S3.
5036        assert!(!is_s3(http::Method::GET, "/_fakecloud/health", &none, &[]));
5037        // Signed or presigned requests keep the catch-all (an IAM-auth API
5038        // signed for `execute-api` must reach API Gateway).
5039        let mut signed = http::HeaderMap::new();
5040        signed.insert(
5041            http::header::AUTHORIZATION,
5042            "AWS4-HMAC-SHA256 Credential=AKID/20240101/us-east-1/execute-api/aws4_request"
5043                .parse()
5044                .unwrap(),
5045        );
5046        assert!(!is_s3(http::Method::GET, "/ghost-bucket/k", &signed, &[]));
5047        assert!(!is_s3(
5048            http::Method::GET,
5049            "/ghost-bucket/k",
5050            &none,
5051            &[(
5052                "X-Amz-Credential",
5053                "AKID/20240101/us-east-1/execute-api/aws4_request"
5054            )]
5055        ));
5056        // S3 query authentication, even incomplete, is S3's on a path API
5057        // Gateway does not claim; on a claimed stage path only when it is
5058        // unmistakable (an s3-scoped credential, two of the SigV2 trio).
5059        for (query, beats_stage) in [
5060            (&[("AWSAccessKeyId", "AKID"), ("Signature", "x")][..], true),
5061            (&[("Signature", "x"), ("Expires", "1")][..], true),
5062            (
5063                &[(
5064                    "X-Amz-Credential",
5065                    "AKID/20240101/us-east-1/s3/aws4_request",
5066                )][..],
5067                true,
5068            ),
5069            (&[("AWSAccessKeyId", "AKID")][..], false),
5070            (&[("Signature", "abc")][..], false),
5071            (&[("X-Amz-Algorithm", "AWS4-HMAC-SHA256")][..], false),
5072            (&[("X-Amz-Signature", "abc")][..], false),
5073        ] {
5074            assert!(is_s3(http::Method::GET, "/ghost-bucket/k", &none, query));
5075            assert_eq!(
5076                is_s3(http::Method::GET, "/prod/items", &none, query),
5077                beats_stage,
5078                "{query:?}"
5079            );
5080        }
5081        let exec = {
5082            let mut h = http::HeaderMap::new();
5083            h.insert(
5084                "host",
5085                "abc123.execute-api.us-east-1.amazonaws.com"
5086                    .parse()
5087                    .unwrap(),
5088            );
5089            h
5090        };
5091        assert_eq!(
5092            target(
5093                &cfg,
5094                http::Method::GET,
5095                "/ghost-bucket/k",
5096                &exec,
5097                &[("AWSAccessKeyId", "AKID")]
5098            ),
5099            Some("apigateway")
5100        );
5101
5102        // An existing bucket beats a stage-name guess but never an
5103        // API Gateway host.
5104        let mut with_prod = DispatchConfig::new("us-east-1", "000000000000");
5105        with_prod.resource_policy_provider = Some(Arc::new(Buckets(&["prod"])));
5106        for method in [http::Method::GET, http::Method::PUT] {
5107            assert_eq!(
5108                target(&with_prod, method.clone(), "/prod/items", &none, &[]),
5109                Some("s3")
5110            );
5111            assert_eq!(
5112                target(&with_prod, method.clone(), "/prod/items", &exec, &[]),
5113                Some("apigateway")
5114            );
5115            assert_eq!(
5116                target(&cfg, method, "/prod/items", &none, &[]),
5117                Some("apigateway")
5118            );
5119        }
5120        // OPTIONS: an API Gateway host's preflight, else S3's; `/v2` is ECR.
5121        assert_eq!(
5122            target(&cfg, http::Method::OPTIONS, "/prod/items", &exec, &[]),
5123            Some("apigateway")
5124        );
5125        assert_eq!(
5126            target(&cfg, http::Method::OPTIONS, "/prod/items", &none, &[]),
5127            Some("s3")
5128        );
5129        assert_eq!(
5130            target(&cfg, http::Method::GET, "/v2/", &none, &[]),
5131            Some("ecr")
5132        );
5133        assert_eq!(
5134            target(&cfg, http::Method::GET, "/_fakecloud/x", &none, &[]),
5135            None
5136        );
5137        assert_eq!(
5138            target(&cfg, http::Method::GET, "/_aws/execute-api/a/b", &none, &[]),
5139            Some("apigateway")
5140        );
5141
5142        assert!(is_unsigned(&none, &HashMap::new()));
5143        assert!(!is_unsigned(&signed, &HashMap::new()));
5144        // An incomplete SigV2 presign is not anonymous.
5145        assert!(!is_unsigned(
5146            &none,
5147            &[("AWSAccessKeyId".to_string(), "AKID".to_string())]
5148                .into_iter()
5149                .collect()
5150        ));
5151        // A form body may still name a Query service.
5152        let mut form = http::HeaderMap::new();
5153        form.insert(
5154            http::header::CONTENT_TYPE,
5155            "application/x-www-form-urlencoded; charset=utf-8"
5156                .parse()
5157                .unwrap(),
5158        );
5159        assert!(protocol::is_form_content_type(&form));
5160        assert!(!protocol::is_form_content_type(&none));
5161
5162        // Without an S3 service registered nothing is routed to it.
5163        let registry = unsigned_routing_registry(false);
5164        assert_eq!(
5165            unrouted_target(
5166                &http::Method::GET,
5167                &"/ghost-bucket/k".parse().unwrap(),
5168                &none,
5169                &HashMap::new(),
5170                &registry,
5171                &cfg,
5172            ),
5173            Some("apigateway")
5174        );
5175    }
5176
5177    #[test]
5178    fn malformed_sigv2_query_auth_is_rejected_as_recorded() {
5179        let q = |pairs: &[(&str, &str)]| -> HashMap<String, String> {
5180            pairs
5181                .iter()
5182                .map(|(k, v)| (k.to_string(), v.to_string()))
5183                .collect()
5184        };
5185        let none = http::HeaderMap::new();
5186        let now = chrono::DateTime::from_timestamp(1_800_000_000, 0).unwrap();
5187        let message = |headers: &http::HeaderMap, pairs: &[(&str, &str)]| {
5188            s3_sigv2_query_auth_error(headers, &q(pairs), now).map(|(m, _)| m)
5189        };
5190        let incomplete = "Query-string authentication requires the Signature, Expires and \
5191                          AWSAccessKeyId parameters";
5192        for pairs in [
5193            &[("AWSAccessKeyId", "AKID"), ("Signature", "x")][..],
5194            &[("AWSAccessKeyId", "AKID"), ("Expires", "1700000000")][..],
5195            &[("Signature", "x")][..],
5196        ] {
5197            assert_eq!(message(&none, pairs).as_deref(), Some(incomplete));
5198        }
5199        let full = |expires: &str| {
5200            s3_sigv2_query_auth_error(
5201                &none,
5202                &q(&[
5203                    ("AWSAccessKeyId", "AKID"),
5204                    ("Signature", "x"),
5205                    ("Expires", expires),
5206                ]),
5207                now,
5208            )
5209        };
5210        // Recorded: 2147483647 is accepted; anything past i32, or not a
5211        // number, is an invalid date.
5212        assert_eq!(full("2147483647"), None);
5213        for bad in [
5214            "2147483648",
5215            "4294967295",
5216            "4294967296",
5217            "9999999999",
5218            "abc",
5219        ] {
5220            assert_eq!(
5221                full(bad),
5222                Some((
5223                    format!("Invalid date (should be seconds since epoch): {bad}"),
5224                    Vec::new()
5225                )),
5226                "{bad}"
5227            );
5228        }
5229        // Recorded: -1 (or any past time) has expired, naming both times.
5230        assert_eq!(
5231            full("-1"),
5232            Some((
5233                "Request has expired".to_string(),
5234                vec![
5235                    ("Expires".to_string(), "1969-12-31T23:59:59Z".to_string()),
5236                    ("ServerTime".to_string(), "2027-01-15T08:00:00Z".to_string()),
5237                ]
5238            ))
5239        );
5240        assert_eq!(
5241            full("1700000000").map(|(m, _)| m).as_deref(),
5242            Some("Request has expired")
5243        );
5244        // Not SigV2 query authentication.
5245        assert_eq!(message(&none, &[("Expires", "x")]), None);
5246        let mut signed = http::HeaderMap::new();
5247        signed.insert(
5248            http::header::AUTHORIZATION,
5249            "AWS4-HMAC-SHA256 x".parse().unwrap(),
5250        );
5251        assert_eq!(message(&signed, &[("Signature", "x")]), None);
5252
5253        let action = |service: &'static str, action: &'static str| crate::auth::IamAction {
5254            service,
5255            action,
5256            resource: "arn:aws:s3:::b".to_string(),
5257        };
5258        assert_eq!(
5259            anonymous_denied_message("s3", &action("s3", "CreateBucket"), "us-east-1"),
5260            "Anonymous users cannot invoke this API. Please authenticate."
5261        );
5262        assert_eq!(
5263            anonymous_denied_message("s3", &action("s3", "GetObject"), "us-east-1"),
5264            "Access Denied"
5265        );
5266    }
5267
5268    #[test]
5269    fn sqs_anonymous_denial_messages() {
5270        // Recorded: tests/fixtures/anonymous-requests/README.md.
5271        let action = |action: &'static str, resource: &str| crate::auth::IamAction {
5272            service: "sqs",
5273            action,
5274            resource: resource.to_string(),
5275        };
5276        assert_eq!(
5277            anonymous_denied_message("sqs", &action("ListQueues", "*"), "us-east-1"),
5278            "Access to the resource https://sqs.us-east-1.amazonaws.com/ is denied."
5279        );
5280        assert_eq!(
5281            anonymous_denied_message("sqs", &action("ListQueues", "*"), "cn-north-1"),
5282            "Access to the resource https://sqs.cn-north-1.amazonaws.com.cn/ is denied."
5283        );
5284        for op in ["ReceiveMessage", "SendMessage", "GetQueueAttributes"] {
5285            let lower = op.to_ascii_lowercase();
5286            assert_eq!(
5287                anonymous_denied_message(
5288                    "sqs",
5289                    &action(op, "arn:aws:sqs:us-east-1:123456789012:jobs"),
5290                    "us-east-1"
5291                ),
5292                format!(
5293                    "User: anonymous is not authorized to perform: sqs:{lower} on resource: \
5294                     arn:aws:sqs:us-east-1:123456789012:jobs because no resource-based policy \
5295                     allows the sqs:{lower} action"
5296                )
5297            );
5298        }
5299    }
5300
5301    async fn missing_auth(
5302        service: &str,
5303        action: &str,
5304        protocol: AwsProtocol,
5305    ) -> (StatusCode, http::HeaderMap, String) {
5306        let detected = protocol::DetectedRequest {
5307            service: service.to_string(),
5308            action: action.to_string(),
5309            protocol,
5310        };
5311        let resp = missing_authentication_response(&detected, "rid", &http::HeaderMap::new());
5312        let status = resp.status();
5313        let headers = resp.headers().clone();
5314        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
5315            .await
5316            .unwrap();
5317        (status, headers, String::from_utf8(body.to_vec()).unwrap())
5318    }
5319
5320    #[tokio::test]
5321    async fn missing_authentication_query_shape() {
5322        // Recorded: tests/fixtures/anonymous-requests/README.md.
5323        for (service, ns) in [
5324            ("sts", "https://sts.amazonaws.com/doc/2011-06-15/"),
5325            ("iam", "https://iam.amazonaws.com/doc/2010-05-08/"),
5326            ("sns", "http://sns.amazonaws.com/doc/2010-03-31/"),
5327        ] {
5328            let (status, _, body) = missing_auth(service, "ListUsers", AwsProtocol::Query).await;
5329            assert_eq!(status, StatusCode::FORBIDDEN);
5330            assert!(
5331                body.contains(&format!(r#"<ErrorResponse xmlns="{ns}">"#)),
5332                "{body}"
5333            );
5334            assert!(body.contains("<Type>Sender</Type>"), "{body}");
5335            assert!(
5336                body.contains("<Code>MissingAuthenticationToken</Code>"),
5337                "{body}"
5338            );
5339            assert!(
5340                body.contains("<Message>Request is missing Authentication Token</Message>"),
5341                "{body}"
5342            );
5343        }
5344    }
5345
5346    #[tokio::test]
5347    async fn missing_authentication_ec2_shape() {
5348        let (status, _, body) = missing_auth("ec2", "DescribeRegions", AwsProtocol::Ec2Query).await;
5349        assert_eq!(status, StatusCode::BAD_REQUEST);
5350        assert!(body.contains("<Response><Errors><Error>"), "{body}");
5351        assert!(body.contains("<Code>MissingParameter</Code>"), "{body}");
5352        assert!(
5353            body.contains("The request must contain the parameter AWSAccessKeyId"),
5354            "{body}"
5355        );
5356    }
5357
5358    #[tokio::test]
5359    async fn missing_authentication_json_shapes() {
5360        let json = |body: &str| serde_json::from_str::<serde_json::Value>(body).unwrap();
5361        for service in ["kms", "ssm", "logs"] {
5362            let (status, _, body) = missing_auth(service, "List", AwsProtocol::Json).await;
5363            assert_eq!(status, StatusCode::BAD_REQUEST);
5364            assert_eq!(
5365                json(&body),
5366                serde_json::json!({
5367                    "__type": "MissingAuthenticationTokenException",
5368                    "message": "Missing Authentication Token"
5369                })
5370            );
5371        }
5372        let (status, _, body) = missing_auth("dynamodb", "ListTables", AwsProtocol::Json).await;
5373        assert_eq!(status, StatusCode::BAD_REQUEST);
5374        assert_eq!(
5375            json(&body),
5376            serde_json::json!({
5377                "__type": "com.amazon.coral.service#MissingAuthenticationTokenException",
5378                "message": "Request is missing Authentication Token"
5379            })
5380        );
5381        let (status, _, body) =
5382            missing_auth("secretsmanager", "ListSecrets", AwsProtocol::Json).await;
5383        assert_eq!(status, StatusCode::BAD_REQUEST);
5384        assert_eq!(
5385            json(&body),
5386            serde_json::json!({
5387                "__type": "UnrecognizedClientException",
5388                "message": "The security token included in the request is invalid."
5389            })
5390        );
5391    }
5392
5393    #[tokio::test]
5394    async fn missing_authentication_rest_shapes() {
5395        for service in ["lambda", "apigateway", "scheduler"] {
5396            let (status, headers, body) = missing_auth(service, "", AwsProtocol::RestJson).await;
5397            assert_eq!(status, StatusCode::FORBIDDEN);
5398            assert_eq!(
5399                serde_json::from_str::<serde_json::Value>(&body).unwrap(),
5400                serde_json::json!({"message": "Missing Authentication Token"})
5401            );
5402            assert_eq!(
5403                headers["x-amzn-errortype"],
5404                "MissingAuthenticationTokenException"
5405            );
5406        }
5407        let (status, _, body) = missing_auth("route53", "", AwsProtocol::Rest).await;
5408        assert_eq!(status, StatusCode::FORBIDDEN);
5409        assert!(
5410            body.contains(
5411                r#"<ErrorResponse xmlns="https://route53.amazonaws.com/doc/2013-04-01/">"#
5412            ),
5413            "{body}"
5414        );
5415        assert!(
5416            body.contains("<Code>MissingAuthenticationToken</Code>"),
5417            "{body}"
5418        );
5419        assert!(
5420            body.contains("<Message>Request is missing Authentication Token</Message>"),
5421            "{body}"
5422        );
5423    }
5424
5425    #[test]
5426    fn only_api_operations_are_judged_for_missing_credentials() {
5427        let detected = |service: &str, action: &str, protocol| protocol::DetectedRequest {
5428            service: service.to_string(),
5429            action: action.to_string(),
5430            protocol,
5431        };
5432        let host = |name: &str| protocol::parse_routing_host(name);
5433        let headers = |name: &str| {
5434            let mut h = http::HeaderMap::new();
5435            h.insert(http::header::HOST, name.parse().unwrap());
5436            h
5437        };
5438        let get = http::Method::GET;
5439        let judged = |d: protocol::DetectedRequest,
5440                      hostname: Option<&str>,
5441                      method: &http::Method,
5442                      control_plane: bool| {
5443            let hdrs = hostname.map(headers).unwrap_or_default();
5444            names_api_operation(&d, hostname.and_then(host).as_ref(), method, &hdrs, || {
5445                control_plane
5446            })
5447        };
5448        assert!(judged(
5449            detected("kms", "ListKeys", AwsProtocol::Json),
5450            None,
5451            &get,
5452            true
5453        ));
5454        assert!(judged(
5455            detected("sts", "GetCallerIdentity", AwsProtocol::Query),
5456            None,
5457            &get,
5458            true
5459        ));
5460        // A REST request is an API call only on its service's API host, and
5461        // only when the service classifies it as a control-plane operation.
5462        let lambda_host = Some("lambda.us-east-1.amazonaws.com");
5463        let lambda = || detected("lambda", "", AwsProtocol::RestJson);
5464        assert!(judged(lambda(), lambda_host, &get, true));
5465        assert!(!judged(lambda(), lambda_host, &get, false));
5466        // A CORS preflight is never one.
5467        assert!(!judged(lambda(), lambda_host, &http::Method::OPTIONS, true));
5468        assert!(judged(
5469            detected("route53", "", AwsProtocol::Rest),
5470            Some("route53.amazonaws.com"),
5471            &get,
5472            true
5473        ));
5474        // Unrouted unsigned requests (API Gateway invocations, ECR pulls)
5475        // are data plane.
5476        assert!(!judged(
5477            detected("apigateway", "", AwsProtocol::RestJson),
5478            None,
5479            &get,
5480            true
5481        ));
5482        // ECR's API is awsJson: a REST request on its host is the registry.
5483        assert!(!judged(
5484            detected("ecr", "", AwsProtocol::Rest),
5485            Some("ecr.us-east-1.amazonaws.com"),
5486            &get,
5487            true
5488        ));
5489        assert!(!judged(
5490            detected("apigateway", "", AwsProtocol::RestJson),
5491            Some("abc123.execute-api.us-east-1.amazonaws.com"),
5492            &get,
5493            true
5494        ));
5495    }
5496
5497    #[test]
5498    fn query_action_stays_a_query_call_without_a_bucket_or_api() {
5499        let config = DispatchConfig {
5500            region: "us-east-1".to_string(),
5501            account_id: "123456789012".to_string(),
5502            verify_sigv4: false,
5503            iam_mode: IamMode::Off,
5504            credential_resolver: None,
5505            policy_evaluator: None,
5506            resource_policy_provider: None,
5507            scp_resolver: None,
5508        };
5509        let registry = ServiceRegistry::new();
5510        let route = |path_and_query: &str, headers: &[(&str, &str)]| {
5511            let uri: http::Uri = path_and_query.parse().unwrap();
5512            let query: HashMap<String, String> = uri
5513                .query()
5514                .unwrap_or("")
5515                .split('&')
5516                .filter_map(|kv| kv.split_once('='))
5517                .map(|(k, v)| (k.to_string(), v.to_string()))
5518                .collect();
5519            let mut h = http::HeaderMap::new();
5520            for (k, v) in headers {
5521                h.insert(
5522                    http::header::HeaderName::from_bytes(k.as_bytes()).unwrap(),
5523                    v.parse().unwrap(),
5524                );
5525            }
5526            unsigned_action_is_a_parameter(&http::Method::GET, &uri, &h, &query, &registry, &config)
5527        };
5528        // No bucket and no API Gateway: the Action is the request's.
5529        // (The bucket and custom-domain cases are end-to-end tested in
5530        // host_routing.rs.)
5531        assert!(!route("/?Action=ListTopics&Version=2010-03-31", &[]));
5532        assert!(!route("/123456789012/q?Action=SendMessage", &[]));
5533        // Only an unsigned request is judged here.
5534        assert!(!route(
5535            "/b/k?Action=ListQueues",
5536            &[(
5537                "authorization",
5538                "AWS4-HMAC-SHA256 Credential=a/b/c/s3/aws4_request"
5539            )]
5540        ));
5541    }
5542
5543    #[test]
5544    fn dispatch_config_carries_opt_in_flags() {
5545        let cfg = DispatchConfig {
5546            region: "eu-west-1".to_string(),
5547            account_id: "000000000000".to_string(),
5548            verify_sigv4: true,
5549            iam_mode: IamMode::Strict,
5550            credential_resolver: None,
5551            policy_evaluator: None,
5552            resource_policy_provider: None,
5553            scp_resolver: None,
5554        };
5555        assert!(cfg.verify_sigv4);
5556        assert!(cfg.iam_mode.is_strict());
5557        assert!(cfg.resource_policy_provider.is_none());
5558        assert!(cfg.scp_resolver.is_none());
5559    }
5560
5561    fn s3_sigv4_headers() -> http::HeaderMap {
5562        let mut headers = http::HeaderMap::new();
5563        headers.insert(
5564            "authorization",
5565            "AWS4-HMAC-SHA256 Credential=test/20240101/us-east-1/s3/aws4_request, \
5566             SignedHeaders=host, Signature=fake"
5567                .parse()
5568                .unwrap(),
5569        );
5570        headers
5571    }
5572
5573    #[test]
5574    fn streaming_route_path_style_s3_put_object() {
5575        let headers = s3_sigv4_headers();
5576        assert_eq!(
5577            streaming_route(
5578                &http::Method::PUT,
5579                "/my-bucket/key.txt",
5580                &headers,
5581                &HashMap::new(),
5582            ),
5583            Some(("s3", "")),
5584        );
5585    }
5586
5587    #[test]
5588    fn streaming_route_any_s3_put_is_a_candidate() {
5589        // Which S3 PUTs stream is S3's decision (`service_streams_body`):
5590        // the route only names the candidates, whatever their path or query.
5591        let headers = s3_sigv4_headers();
5592        let tagging: HashMap<String, String> = [("tagging".to_string(), String::new())].into();
5593        for (path, query) in [
5594            ("/my-bucket", HashMap::new()),
5595            ("/my-bucket/", HashMap::new()),
5596            ("/my-bucket/k", tagging),
5597        ] {
5598            assert_eq!(
5599                streaming_route(&http::Method::PUT, path, &headers, &query),
5600                Some(("s3", "")),
5601                "{path}"
5602            );
5603        }
5604    }
5605
5606    /// Records the request head dispatch asks about, and streams when the
5607    /// path says so.
5608    struct HeadRecorder {
5609        name: &'static str,
5610        seen: parking_lot::Mutex<Vec<(String, Option<String>)>>,
5611    }
5612
5613    #[async_trait::async_trait]
5614    impl crate::service::AwsService for HeadRecorder {
5615        fn service_name(&self) -> &str {
5616            self.name
5617        }
5618        async fn handle(
5619            &self,
5620            _request: AwsRequest,
5621        ) -> Result<crate::service::AwsResponse, crate::service::AwsServiceError> {
5622            unreachable!("only asked whether it streams")
5623        }
5624        fn supported_actions(&self) -> &[&str] {
5625            &[]
5626        }
5627        fn streams_request_body(&self, head: &crate::service::RequestHead<'_>) -> bool {
5628            let tagging = head
5629                .headers
5630                .get("x-amz-tagging")
5631                .and_then(|v| v.to_str().ok())
5632                .map(str::to_string);
5633            self.seen.lock().push((head.raw_path.to_string(), tagging));
5634            head.raw_path.ends_with("/upload")
5635        }
5636    }
5637
5638    #[test]
5639    fn service_streams_body_asks_with_the_request_as_routed() {
5640        // S3 is asked about the path its handler will see (the
5641        // virtual-hosted bucket prefixed) and the headers it will see (a
5642        // presigned URL's `x-amz-*` parameters hoisted); its answer stands.
5643        let s3 = HeadRecorder {
5644            name: "s3",
5645            seen: Default::default(),
5646        };
5647        let mut headers = s3_sigv4_headers();
5648        headers.insert(
5649            "host",
5650            "my-bucket.s3.us-east-1.amazonaws.com".parse().unwrap(),
5651        );
5652        let presigned: HashMap<String, String> = [
5653            (
5654                "X-Amz-Credential",
5655                "test/20240101/us-east-1/s3/aws4_request",
5656            ),
5657            ("x-amz-tagging", "env=test"),
5658        ]
5659        .into_iter()
5660        .map(|(k, v)| (k.to_string(), v.to_string()))
5661        .collect();
5662        let put = http::Method::PUT;
5663        let empty = HashMap::new();
5664        let request = |wire_path, query_params| EarlyRequest {
5665            method: &put,
5666            wire_path,
5667            headers: &headers,
5668            query_params,
5669            anonymous: false,
5670            region: "us-east-1",
5671            request_id: "r",
5672        };
5673        assert!(service_streams_body(&s3, &request("/upload", &presigned)));
5674        assert!(!service_streams_body(&s3, &request("/", &empty)));
5675        assert_eq!(
5676            *s3.seen.lock(),
5677            vec![
5678                (
5679                    "/my-bucket/upload".to_string(),
5680                    Some("env=test".to_string())
5681                ),
5682                ("/my-bucket".to_string(), None),
5683            ]
5684        );
5685        // Another service sees its wire path untouched.
5686        let other = HeadRecorder {
5687            name: "ecr",
5688            seen: Default::default(),
5689        };
5690        assert!(!service_streams_body(
5691            &other,
5692            &request("/my-bucket/k", &presigned)
5693        ));
5694        assert_eq!(*other.seen.lock(), vec![("/my-bucket/k".to_string(), None)]);
5695    }
5696
5697    #[test]
5698    fn s3_routing_path_prefixes_the_host_bucket() {
5699        assert_eq!(s3_routing_path("/", Some("b")), "/b");
5700        assert_eq!(s3_routing_path("", Some("b")), "/b");
5701        assert_eq!(s3_routing_path("/k.txt", Some("b")), "/b/k.txt");
5702        assert_eq!(s3_routing_path("/dir/k", Some("a.b")), "/a.b/dir/k");
5703        assert_eq!(s3_routing_path("/b/k", None), "/b/k");
5704    }
5705
5706    #[test]
5707    fn s3_routing_path_keeps_a_key_that_starts_with_the_bucket_name() {
5708        // The wire path is the whole key on a virtual-hosted request, so a key
5709        // whose first segment equals the bucket name must keep it.
5710        assert_eq!(
5711            s3_routing_path("/docs/intro.html", Some("docs")),
5712            "/docs/docs/intro.html"
5713        );
5714        assert_eq!(s3_routing_path("/docs", Some("docs")), "/docs/docs");
5715    }
5716
5717    #[test]
5718    fn streaming_route_virtual_hosted_s3_put_object() {
5719        let mut headers = s3_sigv4_headers();
5720        headers.insert(
5721            "host",
5722            "vhost-bucket.s3.us-east-1.localhost.localstack.cloud:4566"
5723                .parse()
5724                .unwrap(),
5725        );
5726        // Virtual-hosted PUT has no bucket in the URL path (`/<key>`),
5727        // so the slash check used for path-style would reject it. The
5728        // Host parser confirms this is virtual-hosted S3 and the key
5729        // flows through the streaming dispatch.
5730        assert_eq!(
5731            streaming_route(&http::Method::PUT, "/hello.txt", &headers, &HashMap::new(),),
5732            Some(("s3", "")),
5733        );
5734    }
5735
5736    #[test]
5737    fn streaming_route_virtual_hosted_path_naming_the_bucket_streams() {
5738        // Under virtual-hosted addressing the whole path is the key, as on
5739        // real S3: `PUT /my-bucket` on `Host: my-bucket.s3...` uploads the
5740        // object `my-bucket`, not a CreateBucket, so every one of these
5741        // streams.
5742        let mut headers = s3_sigv4_headers();
5743        headers.insert(
5744            "host",
5745            "my-bucket.s3.us-east-1.amazonaws.com".parse().unwrap(),
5746        );
5747        for path in ["/my-bucket", "/my-bucket/", "/my-bucket/key.txt"] {
5748            assert_eq!(
5749                streaming_route(&http::Method::PUT, path, &headers, &HashMap::new()),
5750                Some(("s3", "")),
5751                "{path}",
5752            );
5753        }
5754    }
5755
5756    #[test]
5757    fn streaming_route_ecr_blob_upload() {
5758        let headers = http::HeaderMap::new();
5759        assert_eq!(
5760            streaming_route(
5761                &http::Method::PATCH,
5762                "/v2/my-repo/blobs/uploads/abcd1234",
5763                &headers,
5764                &HashMap::new(),
5765            ),
5766            Some(("ecr", "")),
5767        );
5768        assert_eq!(
5769            streaming_route(
5770                &http::Method::PUT,
5771                "/v2/my-repo/blobs/uploads/abcd1234",
5772                &headers,
5773                &HashMap::new(),
5774            ),
5775            Some(("ecr", "")),
5776        );
5777    }
5778
5779    #[test]
5780    fn hoist_presigned_query_headers_skips_auth_params_and_keeps_direct_headers() {
5781        let mut headers = http::HeaderMap::new();
5782        headers.insert("x-amz-meta-color", "red".parse().unwrap());
5783        let query: HashMap<String, String> = [
5784            (
5785                "X-Amz-Credential",
5786                "AKID/20260101/us-east-1/s3/aws4_request",
5787            ),
5788            ("X-Amz-Signature", "00"),
5789            ("X-Amz-Security-Token", "tok"),
5790            ("x-amz-meta-color", "blue"),
5791            ("X-Amz-Meta-Shape", "round"),
5792            ("x-amz-meta-name", "café"),
5793            ("x-amz-tagging", "env=test"),
5794            ("response-content-type", "text/plain"),
5795            ("partNumber", "1"),
5796        ]
5797        .into_iter()
5798        .map(|(k, v)| (k.to_string(), v.to_string()))
5799        .collect();
5800        hoist_presigned_query_headers(&mut headers, &query);
5801
5802        assert_eq!(headers["x-amz-meta-color"], "red");
5803        assert_eq!(headers["x-amz-meta-shape"], "round");
5804        assert_eq!(headers["x-amz-meta-name"], "=?UTF-8?B?Y2Fmw6k=?=");
5805        assert_eq!(headers["x-amz-tagging"], "env=test");
5806        for absent in [
5807            "x-amz-credential",
5808            "x-amz-signature",
5809            "x-amz-security-token",
5810            "response-content-type",
5811            "partnumber",
5812        ] {
5813            assert!(headers.get(absent).is_none(), "{absent}");
5814        }
5815    }
5816
5817    #[test]
5818    fn streaming_route_presigned_v4_s3_put() {
5819        let headers = http::HeaderMap::new();
5820        let mut query_params = HashMap::new();
5821        query_params.insert(
5822            "X-Amz-Credential".to_string(),
5823            "test/20240101/us-east-1/s3/aws4_request".to_string(),
5824        );
5825        assert_eq!(
5826            streaming_route(
5827                &http::Method::PUT,
5828                "/my-bucket/key.txt",
5829                &headers,
5830                &query_params,
5831            ),
5832            Some(("s3", "")),
5833        );
5834    }
5835
5836    #[test]
5837    fn streaming_route_non_s3_auth_header_skipped() {
5838        // Same path shape but the SigV4 service is lambda — must not
5839        // wire the streaming dispatch (Lambda has its own buffered path).
5840        let mut headers = http::HeaderMap::new();
5841        headers.insert(
5842            "authorization",
5843            "AWS4-HMAC-SHA256 Credential=test/20240101/us-east-1/lambda/aws4_request, \
5844             SignedHeaders=host, Signature=fake"
5845                .parse()
5846                .unwrap(),
5847        );
5848        assert_eq!(
5849            streaming_route(
5850                &http::Method::PUT,
5851                "/my-bucket/key.txt",
5852                &headers,
5853                &HashMap::new(),
5854            ),
5855            None,
5856        );
5857    }
5858
5859    #[test]
5860    fn streaming_route_get_skipped() {
5861        let headers = s3_sigv4_headers();
5862        assert_eq!(
5863            streaming_route(
5864                &http::Method::GET,
5865                "/my-bucket/key.txt",
5866                &headers,
5867                &HashMap::new(),
5868            ),
5869            None,
5870        );
5871    }
5872
5873    /// Root of account B reaching a bucket account A owns: allowed only when
5874    /// A's resource policy grants it, as for any cross-account principal.
5875    /// Root in its own account, and resources no provider claims, keep the
5876    /// root exemption.
5877    #[test]
5878    fn root_cross_account_needs_the_resource_policy() {
5879        use crate::auth::IamAction;
5880        use crate::service::{AwsResponse, AwsServiceError};
5881        struct OwnerProvider;
5882        impl crate::auth::ResourcePolicyProvider for OwnerProvider {
5883            fn resource_policy(&self, _service: &str, resource_arn: &str) -> Option<String> {
5884                resource_arn
5885                    .starts_with("arn:aws:s3:::granted")
5886                    .then(|| "granting-policy".to_string())
5887            }
5888            fn resource_owner_account(&self, _service: &str, resource_arn: &str) -> Option<String> {
5889                let bucket = resource_arn.strip_prefix("arn:aws:s3:::")?;
5890                let bucket = bucket.split('/').next()?;
5891                match bucket {
5892                    "own" => Some("222222222222".to_string()),
5893                    "granted" | "denied" => Some("111111111111".to_string()),
5894                    _ => None,
5895                }
5896            }
5897        }
5898        struct PolicyEvaluator(parking_lot::Mutex<Vec<ConditionContext>>);
5899        impl IamPolicyEvaluator for PolicyEvaluator {
5900            fn evaluate(
5901                &self,
5902                _: &Principal,
5903                _: &IamAction,
5904                _: &ConditionContext,
5905                _: &[String],
5906                _: Option<&[String]>,
5907            ) -> crate::auth::IamDecision {
5908                crate::auth::IamDecision::ImplicitDeny
5909            }
5910            fn evaluate_with_resource_policy(
5911                &self,
5912                _: &Principal,
5913                _: &IamAction,
5914                _: &ConditionContext,
5915                _: Option<&str>,
5916                _: &str,
5917                _: &[String],
5918                _: Option<&[String]>,
5919            ) -> crate::auth::IamDecision {
5920                crate::auth::IamDecision::ImplicitDeny
5921            }
5922            fn evaluate_resource_policy_only(
5923                &self,
5924                _: &Principal,
5925                _: &IamAction,
5926                context: &ConditionContext,
5927                policy: Option<&str>,
5928            ) -> crate::auth::IamDecision {
5929                self.0.lock().push(context.clone());
5930                if policy == Some("granting-policy") {
5931                    crate::auth::IamDecision::Allow
5932                } else {
5933                    crate::auth::IamDecision::ImplicitDeny
5934                }
5935            }
5936        }
5937        struct BucketService;
5938        #[async_trait::async_trait]
5939        impl crate::service::AwsService for BucketService {
5940            fn service_name(&self) -> &str {
5941                "s3"
5942            }
5943            async fn handle(&self, _: AwsRequest) -> Result<AwsResponse, AwsServiceError> {
5944                unreachable!()
5945            }
5946            fn supported_actions(&self) -> &[&str] {
5947                &[]
5948            }
5949            fn iam_action_for(&self, request: &AwsRequest) -> Option<IamAction> {
5950                Some(IamAction {
5951                    service: "s3",
5952                    action: "GetObject",
5953                    resource: format!("arn:aws:s3:::{}", request.path_segments.join("/")),
5954                })
5955            }
5956        }
5957        let root = Principal {
5958            arn: "arn:aws:iam::222222222222:root".to_string(),
5959            user_id: "222222222222".to_string(),
5960            account_id: "222222222222".to_string(),
5961            principal_type: PrincipalType::Root,
5962            source_identity: None,
5963            tags: None,
5964        };
5965        let detected = protocol::DetectedRequest {
5966            service: "s3".to_string(),
5967            action: String::new(),
5968            protocol: AwsProtocol::Rest,
5969        };
5970        let request = |bucket: &str| AwsRequest {
5971            service: "s3".to_string(),
5972            action: String::new(),
5973            region: "us-east-1".to_string(),
5974            account_id: "222222222222".to_string(),
5975            request_id: "req".to_string(),
5976            headers: http::HeaderMap::new(),
5977            query_params: HashMap::new(),
5978            body: Bytes::new(),
5979            body_stream: parking_lot::Mutex::new(None),
5980            path_segments: vec![bucket.to_string(), "key".to_string()],
5981            raw_path: format!("/{bucket}/key"),
5982            raw_query: String::new(),
5983            method: http::Method::GET,
5984            is_query_protocol: false,
5985            access_key_id: Some("AKIAROOTB".to_string()),
5986            principal: Some(root.clone()),
5987        };
5988        struct Org;
5989        impl crate::auth::ScpResolver for Org {
5990            fn scps_for(&self, _: &Principal) -> Option<Vec<String>> {
5991                None
5992            }
5993            fn principal_org(&self, account: &str) -> Option<(String, String)> {
5994                (account == "222222222222").then(|| ("o-abc".to_string(), "o-abc/r-1/".to_string()))
5995            }
5996        }
5997        let issued = chrono::Utc::now();
5998        let credential = crate::auth::ResolvedCredential {
5999            secret_access_key: "secret".to_string(),
6000            session_token: Some("token".to_string()),
6001            principal: root.clone(),
6002            session_policies: Vec::new(),
6003            mfa_present: true,
6004            token_issued_at: Some(issued),
6005            federated_provider: None,
6006            role_arn: None,
6007            federated_parent_arn: None,
6008        };
6009        let evaluator = PolicyEvaluator(parking_lot::Mutex::new(Vec::new()));
6010        let run = |bucket: &str, mode: IamMode| {
6011            let mut cfg = DispatchConfig::new("us-east-1", "111111111111");
6012            cfg.iam_mode = mode;
6013            cfg.resource_policy_provider = Some(Arc::new(OwnerProvider));
6014            cfg.scp_resolver = Some(Arc::new(Org));
6015            authorize_root_cross_account(
6016                &root,
6017                &BucketService,
6018                &request(bucket),
6019                &evaluator,
6020                &cfg,
6021                &detected,
6022                "req",
6023                None,
6024                Some(&credential),
6025                &crate::service::AwsService::iam_actions_for(&BucketService, &request(bucket)),
6026            )
6027        };
6028        let denied = run("denied", IamMode::Strict).expect("no grant must deny under strict");
6029        assert_eq!(denied.status(), StatusCode::FORBIDDEN);
6030        assert!(run("granted", IamMode::Strict).is_none());
6031        assert!(run("own", IamMode::Strict).is_none());
6032        assert!(run("unclaimed", IamMode::Strict).is_none());
6033        assert!(run("denied", IamMode::Soft).is_none());
6034
6035        // The root path evaluates with the same condition context as any
6036        // other principal: the owner's account, the principal's organization
6037        // and the session keys its credential carries.
6038        let contexts = evaluator.0.lock();
6039        assert!(!contexts.is_empty());
6040        for ctx in contexts.iter() {
6041            assert_eq!(
6042                ctx.lookup("aws:ResourceAccount"),
6043                Some(vec!["111111111111".into()])
6044            );
6045            assert_eq!(ctx.lookup("aws:PrincipalOrgID"), Some(vec!["o-abc".into()]));
6046            assert_eq!(
6047                ctx.lookup("aws:PrincipalOrgPaths"),
6048                Some(vec!["o-abc/r-1/".into()])
6049            );
6050            assert_eq!(ctx.aws_mfa_present, Some(true));
6051            assert_eq!(ctx.aws_token_issue_time, Some(issued));
6052            assert_eq!(ctx.aws_principal_type.as_deref(), Some("Account"));
6053        }
6054    }
6055
6056    #[test]
6057    fn denial_messages_name_the_scp_when_it_refused() {
6058        use crate::auth::IamDecision;
6059        assert_eq!(scp_denial_reason(IamDecision::Allow, "s3:GetObject"), None);
6060        // The SCP layer is named only when it caused the denial: its own
6061        // explicit Deny, or not allowing when nothing else explicitly denied.
6062        use IamDecision::{Allow, ExplicitDeny, ImplicitDeny};
6063        assert_eq!(scp_cause(ExplicitDeny, ExplicitDeny), Some(ExplicitDeny));
6064        assert_eq!(scp_cause(ImplicitDeny, ImplicitDeny), Some(ImplicitDeny));
6065        assert_eq!(scp_cause(ExplicitDeny, ImplicitDeny), None);
6066        assert_eq!(scp_cause(ImplicitDeny, Allow), None);
6067        assert_eq!(scp_cause(ExplicitDeny, Allow), None);
6068        // The denial code follows the protocol: awsQuery `AccessDenied`,
6069        // JSON/REST `AccessDeniedException`, with S3 and SNS their own.
6070        assert_eq!(access_denied_code("s3", AwsProtocol::Rest), "AccessDenied");
6071        assert_eq!(
6072            access_denied_code("sts", AwsProtocol::Query),
6073            "AccessDenied"
6074        );
6075        assert_eq!(
6076            access_denied_code("iam", AwsProtocol::Query),
6077            "AccessDenied"
6078        );
6079        assert_eq!(
6080            access_denied_code("elasticloadbalancing", AwsProtocol::Query),
6081            "AccessDenied"
6082        );
6083        assert_eq!(
6084            access_denied_code("sqs", AwsProtocol::Query),
6085            "AccessDenied"
6086        );
6087        assert_eq!(
6088            access_denied_code("sqs", AwsProtocol::Json),
6089            "AccessDeniedException"
6090        );
6091        assert_eq!(
6092            access_denied_code("sns", AwsProtocol::Query),
6093            "AuthorizationError"
6094        );
6095        assert_eq!(
6096            access_denied_code("kms", AwsProtocol::Json),
6097            "AccessDeniedException"
6098        );
6099        assert_eq!(
6100            access_denied_code("lambda", AwsProtocol::Rest),
6101            "AccessDeniedException"
6102        );
6103        // SQS's JSON path carries the awsQuery code in `x-amzn-query-error`.
6104        let detected = |service: &str, protocol| protocol::DetectedRequest {
6105            service: service.to_string(),
6106            action: "SendMessage".to_string(),
6107            protocol,
6108        };
6109        let headers = http::HeaderMap::new();
6110        let sqs_json =
6111            access_denied_response("m", "r", &detected("sqs", AwsProtocol::Json), &headers);
6112        assert_eq!(
6113            sqs_json.headers()["x-amzn-query-error"],
6114            "AccessDenied;Sender"
6115        );
6116        let kms = access_denied_response("m", "r", &detected("kms", AwsProtocol::Json), &headers);
6117        assert!(kms.headers().get("x-amzn-query-error").is_none());
6118        let explicit = scp_denial_reason(IamDecision::ExplicitDeny, "s3:GetObject");
6119        let implicit = scp_denial_reason(IamDecision::ImplicitDeny, "s3:GetObject");
6120        let user = "arn:aws:iam::222222222222:user/u";
6121        assert_eq!(
6122            not_authorized_message(user, "s3:GetObject", "arn:aws:s3:::b/k", explicit.as_deref(), None),
6123            "User: arn:aws:iam::222222222222:user/u is not authorized to perform: s3:GetObject on resource: arn:aws:s3:::b/k with an explicit deny in a service control policy"
6124        );
6125        assert_eq!(
6126            not_authorized_message(user, "s3:GetObject", "arn:aws:s3:::b/k", implicit.as_deref(), Some("BLOB")),
6127            "User: arn:aws:iam::222222222222:user/u is not authorized to perform: s3:GetObject on resource: arn:aws:s3:::b/k because no service control policy allows the s3:GetObject action Encoded authorization failure message: BLOB"
6128        );
6129        assert_eq!(
6130            not_authorized_message(user, "s3:GetObject", "arn:aws:s3:::b/k", None, Some("BLOB")),
6131            "User: arn:aws:iam::222222222222:user/u is not authorized to perform: s3:GetObject on resource: arn:aws:s3:::b/k Encoded authorization failure message: BLOB"
6132        );
6133    }
6134
6135    /// SCPs cap a member account's root user as they cap every principal of
6136    /// the account: in its own account, and across accounts even where the
6137    /// resource policy grants it. No SCPs (the management account, no
6138    /// organization) leave root as it was.
6139    #[tokio::test]
6140    async fn root_requests_are_capped_by_scps() {
6141        use crate::auth::IamAction;
6142        use crate::service::{AwsResponse, AwsServiceError};
6143        struct OwnerProvider;
6144        impl crate::auth::ResourcePolicyProvider for OwnerProvider {
6145            fn resource_policy(&self, _service: &str, _resource_arn: &str) -> Option<String> {
6146                Some("granting-policy".to_string())
6147            }
6148            fn resource_owner_account(&self, _service: &str, resource_arn: &str) -> Option<String> {
6149                let bucket = resource_arn.strip_prefix("arn:aws:s3:::")?;
6150                match bucket.split('/').next()? {
6151                    "own" => Some("222222222222".to_string()),
6152                    _ => Some("111111111111".to_string()),
6153                }
6154            }
6155        }
6156        /// Allows what the resource policy grants; the ceiling allows when
6157        /// there are SCPs and every one is `allow`, and an SCP `deny` denies
6158        /// explicitly.
6159        struct PolicyEvaluator(parking_lot::Mutex<Vec<ConditionContext>>);
6160        impl IamPolicyEvaluator for PolicyEvaluator {
6161            fn evaluate(
6162                &self,
6163                _: &Principal,
6164                _: &IamAction,
6165                _: &ConditionContext,
6166                _: &[String],
6167                _: Option<&[String]>,
6168            ) -> crate::auth::IamDecision {
6169                crate::auth::IamDecision::ImplicitDeny
6170            }
6171            fn evaluate_with_resource_policy(
6172                &self,
6173                _: &Principal,
6174                _: &IamAction,
6175                _: &ConditionContext,
6176                _: Option<&str>,
6177                _: &str,
6178                _: &[String],
6179                _: Option<&[String]>,
6180            ) -> crate::auth::IamDecision {
6181                crate::auth::IamDecision::ImplicitDeny
6182            }
6183            fn evaluate_resource_policy_only(
6184                &self,
6185                _: &Principal,
6186                _: &IamAction,
6187                _: &ConditionContext,
6188                policy: Option<&str>,
6189            ) -> crate::auth::IamDecision {
6190                if policy == Some("granting-policy") {
6191                    crate::auth::IamDecision::Allow
6192                } else {
6193                    crate::auth::IamDecision::ImplicitDeny
6194                }
6195            }
6196            fn evaluate_ceiling(
6197                &self,
6198                _: &Principal,
6199                _: &IamAction,
6200                context: &ConditionContext,
6201                session_policies: &[String],
6202                scps: Option<&[String]>,
6203                with_boundary: bool,
6204            ) -> crate::auth::IamDecision {
6205                assert!(session_policies.is_empty() && !with_boundary);
6206                self.0.lock().push(context.clone());
6207                let scps = scps.unwrap_or_default();
6208                if scps.iter().any(|s| s == "deny") {
6209                    crate::auth::IamDecision::ExplicitDeny
6210                } else if !scps.is_empty() && scps.iter().all(|s| s == "allow") {
6211                    crate::auth::IamDecision::Allow
6212                } else {
6213                    crate::auth::IamDecision::ImplicitDeny
6214                }
6215            }
6216        }
6217        struct BucketService;
6218        #[async_trait::async_trait]
6219        impl crate::service::AwsService for BucketService {
6220            fn service_name(&self) -> &str {
6221                "s3"
6222            }
6223            async fn handle(&self, _: AwsRequest) -> Result<AwsResponse, AwsServiceError> {
6224                unreachable!()
6225            }
6226            fn supported_actions(&self) -> &[&str] {
6227                &[]
6228            }
6229            fn iam_action_for(&self, request: &AwsRequest) -> Option<IamAction> {
6230                Some(IamAction {
6231                    service: "s3",
6232                    action: "GetObject",
6233                    resource: format!("arn:aws:s3:::{}", request.path_segments.join("/")),
6234                })
6235            }
6236        }
6237        struct Org(Option<Vec<String>>);
6238        impl crate::auth::ScpResolver for Org {
6239            fn scps_for(&self, _: &Principal) -> Option<Vec<String>> {
6240                self.0.clone()
6241            }
6242            fn principal_org(&self, _: &str) -> Option<(String, String)> {
6243                Some(("o-abc".to_string(), "o-abc/r-1/".to_string()))
6244            }
6245        }
6246        let root = Principal {
6247            arn: "arn:aws:iam::222222222222:root".to_string(),
6248            user_id: "222222222222".to_string(),
6249            account_id: "222222222222".to_string(),
6250            principal_type: PrincipalType::Root,
6251            source_identity: None,
6252            tags: None,
6253        };
6254        let detected = protocol::DetectedRequest {
6255            service: "s3".to_string(),
6256            action: String::new(),
6257            protocol: AwsProtocol::Rest,
6258        };
6259        let request = |bucket: &str| AwsRequest {
6260            service: "s3".to_string(),
6261            action: String::new(),
6262            region: "us-east-1".to_string(),
6263            account_id: "222222222222".to_string(),
6264            request_id: "req".to_string(),
6265            headers: http::HeaderMap::new(),
6266            query_params: HashMap::new(),
6267            body: Bytes::new(),
6268            body_stream: parking_lot::Mutex::new(None),
6269            path_segments: vec![bucket.to_string(), "key".to_string()],
6270            raw_path: format!("/{bucket}/key"),
6271            raw_query: String::new(),
6272            method: http::Method::GET,
6273            is_query_protocol: false,
6274            access_key_id: Some("ASIAROOTB".to_string()),
6275            principal: Some(root.clone()),
6276        };
6277        let evaluator = PolicyEvaluator(parking_lot::Mutex::new(Vec::new()));
6278        let run = |bucket: &str, scps: Option<&[&str]>, mode: IamMode| {
6279            let mut cfg = DispatchConfig::new("us-east-1", "111111111111");
6280            cfg.iam_mode = mode;
6281            cfg.resource_policy_provider = Some(Arc::new(OwnerProvider));
6282            cfg.scp_resolver = Some(Arc::new(Org(
6283                scps.map(|s| s.iter().map(|d| d.to_string()).collect())
6284            )));
6285            authorize_root(
6286                &root,
6287                &BucketService,
6288                &request(bucket),
6289                &evaluator,
6290                &cfg,
6291                &detected,
6292                "req",
6293                None,
6294                None,
6295                &crate::service::AwsService::iam_actions_for(&BucketService, &request(bucket)),
6296                false,
6297            )
6298        };
6299        async fn message(resp: Response<Body>) -> String {
6300            assert_eq!(resp.status(), StatusCode::FORBIDDEN);
6301            let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
6302                .await
6303                .unwrap();
6304            String::from_utf8(body.to_vec()).unwrap()
6305        }
6306        for bucket in ["own", "granted"] {
6307            // No SCPs apply (management account, no organization).
6308            assert!(run(bucket, None, IamMode::Strict).is_none(), "{bucket}");
6309            // SCPs allowing the action.
6310            assert!(
6311                run(bucket, Some(&["allow"]), IamMode::Strict).is_none(),
6312                "{bucket}"
6313            );
6314            // An SCP denying the action, explicitly or by not allowing it.
6315            let explicit = message(
6316                run(bucket, Some(&["allow", "deny"]), IamMode::Strict)
6317                    .expect("an SCP deny must refuse root"),
6318            )
6319            .await;
6320            assert!(
6321                explicit.contains("<Code>AccessDenied</Code>")
6322                    && explicit.contains(&format!(
6323                        "<Message>User: arn:aws:iam::222222222222:root is not authorized to perform: s3:GetObject on resource: arn:aws:s3:::{bucket}/key with an explicit deny in a service control policy</Message>"
6324                    )),
6325                "{explicit}"
6326            );
6327            let implicit = message(
6328                run(bucket, Some(&["narrow"]), IamMode::Strict)
6329                    .expect("an SCP not allowing the action must refuse root"),
6330            )
6331            .await;
6332            assert!(
6333                implicit.contains("<Code>AccessDenied</Code>")
6334                    && implicit.contains(&format!(
6335                        "<Message>User: arn:aws:iam::222222222222:root is not authorized to perform: s3:GetObject on resource: arn:aws:s3:::{bucket}/key because no service control policy allows the s3:GetObject action</Message>"
6336                    )),
6337                "{implicit}"
6338            );
6339            assert!(
6340                run(bucket, Some(&[]), IamMode::Strict).is_some(),
6341                "{bucket}"
6342            );
6343            // Soft mode logs and lets the request through.
6344            assert!(
6345                run(bucket, Some(&["deny"]), IamMode::Soft).is_none(),
6346                "{bucket}"
6347            );
6348        }
6349
6350        // An operation with no IAM action mapping cannot be checked against
6351        // the SCPs: under strict it fails closed, as for any principal.
6352        struct UnmappedService;
6353        #[async_trait::async_trait]
6354        impl crate::service::AwsService for UnmappedService {
6355            fn service_name(&self) -> &str {
6356                "s3"
6357            }
6358            async fn handle(&self, _: AwsRequest) -> Result<AwsResponse, AwsServiceError> {
6359                unreachable!()
6360            }
6361            fn supported_actions(&self) -> &[&str] {
6362                &[]
6363            }
6364            fn iam_action_for(&self, _: &AwsRequest) -> Option<IamAction> {
6365                None
6366            }
6367        }
6368        let unmapped = |scps: Option<&[&str]>, mode: IamMode| {
6369            let mut cfg = DispatchConfig::new("us-east-1", "111111111111");
6370            cfg.iam_mode = mode;
6371            cfg.resource_policy_provider = Some(Arc::new(OwnerProvider));
6372            cfg.scp_resolver = Some(Arc::new(Org(
6373                scps.map(|s| s.iter().map(|d| d.to_string()).collect())
6374            )));
6375            authorize_root(
6376                &root,
6377                &UnmappedService,
6378                &request("own"),
6379                &evaluator,
6380                &cfg,
6381                &detected,
6382                "req",
6383                None,
6384                None,
6385                &crate::service::AwsService::iam_actions_for(&UnmappedService, &request("own")),
6386                false,
6387            )
6388        };
6389        let refused = message(
6390            unmapped(Some(&["allow"]), IamMode::Strict)
6391                .expect("an unmapped operation under SCPs must fail closed"),
6392        )
6393        .await;
6394        assert!(
6395            refused.contains("<Code>AccessDenied</Code>")
6396                && refused.contains("no IAM action mapping exists for this operation"),
6397            "{refused}"
6398        );
6399        assert!(unmapped(Some(&["allow"]), IamMode::Soft).is_none());
6400        // No SCPs to apply: root needs no identity policy, so nothing to
6401        // check.
6402        assert!(unmapped(None, IamMode::Strict).is_none());
6403        // The SCPs see the request's global keys: the resource's owner and
6404        // the caller's organization.
6405        let contexts = evaluator.0.lock();
6406        assert!(contexts
6407            .iter()
6408            .any(|c| c.lookup("aws:ResourceAccount") == Some(vec!["111111111111".into()])));
6409        assert!(contexts
6410            .iter()
6411            .any(|c| c.lookup("aws:ResourceAccount") == Some(vec!["222222222222".into()])));
6412        for ctx in contexts.iter() {
6413            assert_eq!(ctx.lookup("aws:PrincipalOrgID"), Some(vec!["o-abc".into()]));
6414            assert_eq!(
6415                ctx.aws_principal_arn.as_deref(),
6416                Some("arn:aws:iam::222222222222:root")
6417            );
6418        }
6419    }
6420}
6421
6422/// Whether the request is addressed to an API Gateway execute-api host
6423/// (`{api-id}.execute-api.{region}.amazonaws.com`, or a local alias of it).
6424fn is_execute_api_host(headers: &http::HeaderMap) -> bool {
6425    headers
6426        .get(http::header::HOST)
6427        .and_then(|v| v.to_str().ok())
6428        .is_some_and(|host| host.contains(".execute-api."))
6429}
6430
6431#[cfg(test)]
6432mod execute_api_host_tests {
6433    use super::is_execute_api_host;
6434
6435    #[test]
6436    fn recognizes_execute_api_hosts_only() {
6437        let mut h = http::HeaderMap::new();
6438        h.insert(
6439            http::header::HOST,
6440            "abc123.execute-api.us-east-1.amazonaws.com"
6441                .parse()
6442                .unwrap(),
6443        );
6444        assert!(is_execute_api_host(&h));
6445        h.insert(http::header::HOST, "localhost:4566".parse().unwrap());
6446        assert!(!is_execute_api_host(&h));
6447        assert!(!is_execute_api_host(&http::HeaderMap::new()));
6448    }
6449}