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 std::collections::HashMap;
6use std::net::SocketAddr;
7use std::sync::Arc;
8
9use crate::auth::{
10    is_root_bypass, ConditionContext, CredentialResolver, IamMode, IamPolicyEvaluator, Principal,
11    PrincipalType, ResourcePolicyProvider,
12};
13use crate::protocol::{self, AwsProtocol};
14use crate::registry::ServiceRegistry;
15use crate::service::{AwsRequest, ResponseBody};
16
17/// The main dispatch handler. All HTTP requests come through here.
18pub async fn dispatch(
19    ConnectInfo(remote_addr): ConnectInfo<SocketAddr>,
20    Extension(registry): Extension<Arc<ServiceRegistry>>,
21    Extension(config): Extension<Arc<DispatchConfig>>,
22    Query(query_params): Query<HashMap<String, String>>,
23    request: Request<Body>,
24) -> Response<Body> {
25    let remote_addr = Some(remote_addr);
26    let request_id = uuid::Uuid::new_v4().to_string();
27
28    let (parts, body) = request.into_parts();
29    // TODO: plumb streaming request bodies end-to-end to remove the cap.
30    // 128 MiB comfortably covers every legitimate single-PutObject (AWS
31    // recommends multipart above ~100 MiB) and each multipart part is
32    // dispatched through here separately, so a 20 GiB upload stays under this
33    // limit per-request.
34    const MAX_BODY_BYTES: usize = 128 * 1024 * 1024;
35    let body_bytes = match axum::body::to_bytes(body, MAX_BODY_BYTES).await {
36        Ok(b) => b,
37        Err(_) => {
38            return build_error_response(
39                StatusCode::PAYLOAD_TOO_LARGE,
40                "RequestEntityTooLarge",
41                "Request body too large",
42                &request_id,
43                AwsProtocol::Query,
44            );
45        }
46    };
47
48    // Detect service and action
49    let detected = match protocol::detect_service(&parts.headers, &query_params, &body_bytes) {
50        Some(d) => d,
51        None => {
52            // OPTIONS requests (CORS preflight) don't carry Authorization headers.
53            // Route them to S3 since S3 is the only REST service that handles CORS.
54            // Note: API Gateway CORS preflight is not fully supported in this emulator
55            // because we can't distinguish between S3 and API Gateway OPTIONS requests
56            // without additional context (in real AWS, they have different domains).
57            if parts.method == http::Method::OPTIONS {
58                protocol::DetectedRequest {
59                    service: "s3".to_string(),
60                    action: String::new(),
61                    protocol: AwsProtocol::Rest,
62                }
63            } else if !parts.uri.path().starts_with("/_") {
64                // Requests without AWS auth that don't match any service might be
65                // API Gateway execute API calls (plain HTTP without signatures).
66                // Route them to apigateway service which will validate if a matching
67                // API/stage exists. Skip special FakeCloud endpoints (/_*).
68                protocol::DetectedRequest {
69                    service: "apigateway".to_string(),
70                    action: String::new(),
71                    protocol: AwsProtocol::RestJson,
72                }
73            } else {
74                return build_error_response(
75                    StatusCode::BAD_REQUEST,
76                    "MissingAction",
77                    "Could not determine target service or action from request",
78                    &request_id,
79                    AwsProtocol::Query,
80                );
81            }
82        }
83    };
84
85    // Look up service
86    let service = match registry.get(&detected.service) {
87        Some(s) => s,
88        None => {
89            return build_error_response(
90                detected.protocol.error_status(),
91                "UnknownService",
92                &format!("Service '{}' is not available", detected.service),
93                &request_id,
94                detected.protocol,
95            );
96        }
97    };
98
99    // Extract region and access key from auth header (or presigned query).
100    let auth_header = parts
101        .headers
102        .get("authorization")
103        .and_then(|v| v.to_str().ok())
104        .unwrap_or("");
105    let header_info = fakecloud_aws::sigv4::parse_sigv4(auth_header);
106    let presigned_info = if header_info.is_none() {
107        // Presigned URL: credentials live in the query string.
108        fakecloud_aws::sigv4::parse_sigv4_presigned(&query_params).map(|p| p.as_info())
109    } else {
110        None
111    };
112    let sigv4_info = header_info.or(presigned_info);
113    let access_key_id = sigv4_info.as_ref().map(|info| info.access_key.clone());
114
115    // Host-header routing hint: LocalStack-shaped
116    // `<svc>.<region>.localhost.localstack.cloud[:port]`, real-AWS
117    // `<svc>.<region>.amazonaws.com`, and every S3 virtual-hosted variant
118    // of both. Secondary region source and carries the bucket for
119    // virtual-hosted S3 path rewrite.
120    let host_info = protocol::parse_routing_host_from_headers(&parts.headers);
121
122    let region = sigv4_info
123        .map(|info| info.region)
124        .or_else(|| host_info.as_ref().map(|h| h.region.clone()))
125        .or_else(|| extract_region_from_user_agent(&parts.headers))
126        .unwrap_or_else(|| config.region.clone());
127
128    // Resolve the caller's principal up front so both SigV4 verification
129    // (which needs the secret) and the service handler (which needs the
130    // identity for GetCallerIdentity and IAM enforcement) share a single
131    // lookup. The root-bypass AKID skips resolution entirely — `test`
132    // credentials have no backing identity and must always pass.
133    let caller_akid = access_key_id.as_deref().unwrap_or("");
134    let resolved = if !caller_akid.is_empty() && !is_root_bypass(caller_akid) {
135        config
136            .credential_resolver
137            .as_ref()
138            .and_then(|r| r.resolve(caller_akid))
139    } else {
140        None
141    };
142    let caller_principal = resolved.as_ref().map(|r| r.principal.clone());
143    let caller_session_policies = resolved
144        .as_ref()
145        .map(|r| r.session_policies.clone())
146        .unwrap_or_default();
147
148    // Opt-in SigV4 cryptographic verification. Runs before the service
149    // handler so a failing signature never reaches business logic. The
150    // reserved `test*` root identity short-circuits verification to keep
151    // local-dev workflows frictionless.
152    if config.verify_sigv4 && !is_root_bypass(caller_akid) && config.credential_resolver.is_some() {
153        let amz_date = parts
154            .headers
155            .get("x-amz-date")
156            .and_then(|v| v.to_str().ok());
157        let parsed = fakecloud_aws::sigv4::parse_sigv4_header(auth_header, amz_date)
158            .or_else(|| fakecloud_aws::sigv4::parse_sigv4_presigned(&query_params));
159        let parsed = match parsed {
160            Some(p) => p,
161            None => {
162                return build_error_response(
163                    StatusCode::FORBIDDEN,
164                    "IncompleteSignature",
165                    "Request is missing or has a malformed AWS Signature",
166                    &request_id,
167                    detected.protocol,
168                );
169            }
170        };
171        let resolved_for_verify = match resolved.as_ref() {
172            Some(r) => r,
173            None => {
174                return build_error_response(
175                    StatusCode::FORBIDDEN,
176                    "InvalidClientTokenId",
177                    "The security token included in the request is invalid",
178                    &request_id,
179                    detected.protocol,
180                );
181            }
182        };
183        let headers_vec = fakecloud_aws::sigv4::headers_from_http(&parts.headers);
184        let raw_query_for_verify = parts.uri.query().unwrap_or("").to_string();
185        let verify_req = fakecloud_aws::sigv4::VerifyRequest {
186            method: parts.method.as_str(),
187            path: parts.uri.path(),
188            query: &raw_query_for_verify,
189            headers: &headers_vec,
190            body: &body_bytes,
191        };
192        match fakecloud_aws::sigv4::verify(
193            &parsed,
194            &verify_req,
195            &resolved_for_verify.secret_access_key,
196            chrono::Utc::now(),
197        ) {
198            Ok(()) => {}
199            Err(fakecloud_aws::sigv4::SigV4Error::RequestTimeTooSkewed { .. }) => {
200                return build_error_response(
201                    StatusCode::FORBIDDEN,
202                    "RequestTimeTooSkewed",
203                    "The difference between the request time and the current time is too large",
204                    &request_id,
205                    detected.protocol,
206                );
207            }
208            Err(fakecloud_aws::sigv4::SigV4Error::InvalidDate(msg)) => {
209                return build_error_response(
210                    StatusCode::FORBIDDEN,
211                    "IncompleteSignature",
212                    &format!("Invalid x-amz-date: {msg}"),
213                    &request_id,
214                    detected.protocol,
215                );
216            }
217            Err(fakecloud_aws::sigv4::SigV4Error::Malformed(msg)) => {
218                return build_error_response(
219                    StatusCode::FORBIDDEN,
220                    "IncompleteSignature",
221                    &format!("Malformed SigV4 signature: {msg}"),
222                    &request_id,
223                    detected.protocol,
224                );
225            }
226            Err(fakecloud_aws::sigv4::SigV4Error::SignatureMismatch) => {
227                return build_error_response(
228                    StatusCode::FORBIDDEN,
229                    "SignatureDoesNotMatch",
230                    "The request signature we calculated does not match the signature you provided",
231                    &request_id,
232                    detected.protocol,
233                );
234            }
235        }
236    }
237
238    // Build path segments. For S3 virtual-hosted-style requests the bucket
239    // lives in the Host header, not the path — prepend it so the S3 handler
240    // sees a uniform path-style request. SigV4 verification above already
241    // ran against the wire path, so this rewrite is signature-safe.
242    let wire_path = parts.uri.path();
243    let path = if detected.service == "s3" {
244        if let Some(bucket) = host_info.as_ref().and_then(|h| h.bucket.as_deref()) {
245            let prefix_with_slash = format!("/{bucket}/");
246            let is_bucket_root = wire_path.trim_end_matches('/') == format!("/{bucket}");
247            if wire_path.starts_with(&prefix_with_slash) || is_bucket_root {
248                wire_path.to_string()
249            } else if wire_path == "/" || wire_path.is_empty() {
250                format!("/{bucket}")
251            } else {
252                format!("/{bucket}{wire_path}")
253            }
254        } else {
255            wire_path.to_string()
256        }
257    } else {
258        wire_path.to_string()
259    };
260    let raw_query = parts.uri.query().unwrap_or("").to_string();
261    let path_segments: Vec<String> = path
262        .split('/')
263        .filter(|s| !s.is_empty())
264        .map(|s| s.to_string())
265        .collect();
266
267    // For JSON protocol, validate that non-empty bodies are valid JSON
268    if detected.protocol == AwsProtocol::Json
269        && !body_bytes.is_empty()
270        && serde_json::from_slice::<serde_json::Value>(&body_bytes).is_err()
271    {
272        return build_error_response(
273            StatusCode::BAD_REQUEST,
274            "SerializationException",
275            "Start of structure or map found where not expected",
276            &request_id,
277            AwsProtocol::Json,
278        );
279    }
280
281    // Merge query params with form body params for Query protocol
282    let mut all_params = query_params;
283    if detected.protocol == AwsProtocol::Query {
284        let body_params = protocol::parse_query_body(&body_bytes);
285        for (k, v) in body_params {
286            all_params.entry(k).or_insert(v);
287        }
288    }
289
290    let aws_request = AwsRequest {
291        service: detected.service.clone(),
292        action: detected.action.clone(),
293        region,
294        account_id: caller_principal
295            .as_ref()
296            .map(|p| p.account_id.clone())
297            .unwrap_or_else(|| config.account_id.clone()),
298        request_id: request_id.clone(),
299        headers: parts.headers,
300        query_params: all_params,
301        body: body_bytes,
302        path_segments,
303        raw_path: path,
304        raw_query,
305        method: parts.method,
306        is_query_protocol: detected.protocol == AwsProtocol::Query,
307        access_key_id,
308        principal: caller_principal,
309    };
310
311    tracing::info!(
312        service = %aws_request.service,
313        action = %aws_request.action,
314        request_id = %aws_request.request_id,
315        "handling request"
316    );
317
318    // Opt-in IAM identity-policy enforcement. Runs before the service
319    // handler so a deny never reaches business logic. Root principals
320    // (both `test*` bypass AKIDs and the account's IAM root) are exempt,
321    // matching AWS behavior. Services that haven't opted in via
322    // `iam_enforceable()` are transparently skipped — the startup log
323    // lists which services are under enforcement so users always know.
324    if config.iam_mode.is_enabled()
325        && service.iam_enforceable()
326        && !is_root_bypass(aws_request.access_key_id.as_deref().unwrap_or(""))
327    {
328        if let Some(evaluator) = config.policy_evaluator.as_ref() {
329            if let Some(principal) = aws_request.principal.as_ref() {
330                if !principal.is_root() {
331                    if let Some(iam_action) = service.iam_action_for(&aws_request) {
332                        let mut condition_context = build_condition_context(
333                            principal,
334                            remote_addr,
335                            &aws_request.region,
336                            is_secure_transport(&aws_request.headers),
337                        );
338                        condition_context.service_keys =
339                            service.iam_condition_keys_for(&aws_request, &iam_action);
340
341                        // ABAC: populate tag-based condition keys.
342                        // aws:ResourceTag/*
343                        match service.resource_tags_for(&iam_action.resource) {
344                            Some(tags) => condition_context.resource_tags = Some(tags),
345                            None => tracing::debug!(
346                                target: "fakecloud::iam::audit",
347                                service = %detected.service,
348                                resource = %iam_action.resource,
349                                "service does not expose resource tags for ABAC; skipping aws:ResourceTag/* evaluation"
350                            ),
351                        }
352                        // aws:RequestTag/* + aws:TagKeys
353                        match service.request_tags_from(&aws_request, iam_action.action) {
354                            Some(tags) => condition_context.request_tags = Some(tags),
355                            None => tracing::debug!(
356                                target: "fakecloud::iam::audit",
357                                service = %detected.service,
358                                action = %iam_action.action_string(),
359                                "service does not expose request tags for ABAC; skipping aws:RequestTag/* / aws:TagKeys evaluation"
360                            ),
361                        }
362                        // aws:PrincipalTag/*
363                        condition_context.principal_tags = principal.tags.clone();
364
365                        // Phase 2: fetch the resource-based policy (if
366                        // any) attached to the target resource and
367                        // pass it to the evaluator alongside the
368                        // principal's identity policies. The resource's
369                        // owning account is parsed from the ARN (#381
370                        // multi-account alignment); S3 ARNs have an
371                        // empty account field, so we fall back to the
372                        // server's configured account ID in that case.
373                        let resource_policy_json =
374                            config.resource_policy_provider.as_ref().and_then(|p| {
375                                p.resource_policy(&detected.service, &iam_action.resource)
376                            });
377                        // Derive the resource-owning account from the
378                        // resource ARN. Wildcard (`*`) means the action
379                        // isn't scoped to a specific resource (e.g.
380                        // ListQueues, GetCallerIdentity) — treat it as
381                        // same-account by using the caller's account.
382                        let resource_account_id = parse_account_from_arn(&iam_action.resource)
383                            .unwrap_or_else(|| principal.account_id.clone());
384                        // SCP ceiling: resolve the inherited SCP chain
385                        // for this principal (management accounts and
386                        // service-linked roles come back as `None`, in
387                        // which case the evaluator treats the layer as
388                        // absent). Audit breadcrumbs emitted by the
389                        // resolver itself, not here.
390                        let scps = config
391                            .scp_resolver
392                            .as_ref()
393                            .and_then(|r| r.scps_for(principal));
394                        let decision = evaluator.evaluate_with_resource_policy(
395                            principal,
396                            &iam_action,
397                            &condition_context,
398                            resource_policy_json.as_deref(),
399                            &resource_account_id,
400                            &caller_session_policies,
401                            scps.as_deref(),
402                        );
403                        if !decision.is_allow() {
404                            tracing::warn!(
405                                target: "fakecloud::iam::audit",
406                                service = %detected.service,
407                                action = %iam_action.action_string(),
408                                resource = %iam_action.resource,
409                                principal = %principal.arn,
410                                resource_policy_present = resource_policy_json.is_some(),
411                                decision = ?decision,
412                                mode = %config.iam_mode,
413                                request_id = %request_id,
414                                "IAM policy evaluation denied request"
415                            );
416                            if config.iam_mode.is_strict() {
417                                return build_error_response(
418                                    StatusCode::FORBIDDEN,
419                                    "AccessDeniedException",
420                                    &format!(
421                                        "User: {} is not authorized to perform: {} on resource: {}",
422                                        principal.arn,
423                                        iam_action.action_string(),
424                                        iam_action.resource
425                                    ),
426                                    &request_id,
427                                    detected.protocol,
428                                );
429                            }
430                            // Soft mode: audit log already emitted; fall
431                            // through to the handler.
432                        }
433                    } else {
434                        // Service opted in but didn't return an IamAction
435                        // for this specific operation — programming bug,
436                        // surface it loudly in soft/strict mode so it's
437                        // visible during rollout.
438                        tracing::warn!(
439                            target: "fakecloud::iam::audit",
440                            service = %detected.service,
441                            action = %aws_request.action,
442                            "service is iam_enforceable but has no IamAction mapping for this action; skipping evaluation"
443                        );
444                    }
445                }
446            }
447        }
448    }
449
450    match service.handle(aws_request).await {
451        Ok(resp) => {
452            let mut builder = Response::builder()
453                .status(resp.status)
454                .header("x-amzn-requestid", &request_id)
455                .header("x-amz-request-id", &request_id);
456
457            if !resp.content_type.is_empty() {
458                builder = builder.header("content-type", &resp.content_type);
459            }
460
461            let has_content_length = resp
462                .headers
463                .iter()
464                .any(|(k, _)| k.as_str().eq_ignore_ascii_case("content-length"));
465
466            for (k, v) in &resp.headers {
467                builder = builder.header(k, v);
468            }
469
470            match resp.body {
471                ResponseBody::Bytes(b) => builder.body(Body::from(b)).unwrap(),
472                ResponseBody::File { file, size } => {
473                    let stream = tokio_util::io::ReaderStream::new(file);
474                    let body = Body::from_stream(stream);
475                    if !has_content_length {
476                        builder = builder.header("content-length", size.to_string());
477                    }
478                    builder.body(body).unwrap()
479                }
480            }
481        }
482        Err(err) => {
483            tracing::warn!(
484                service = %detected.service,
485                action = %detected.action,
486                error = %err,
487                "request failed"
488            );
489            let error_headers = err.response_headers().to_vec();
490            let mut resp = build_error_response_with_fields(
491                err.status(),
492                err.code(),
493                &err.message(),
494                &request_id,
495                detected.protocol,
496                err.extra_fields(),
497            );
498            for (k, v) in &error_headers {
499                if let (Ok(name), Ok(val)) = (
500                    k.parse::<http::header::HeaderName>(),
501                    v.parse::<http::header::HeaderValue>(),
502                ) {
503                    resp.headers_mut().insert(name, val);
504                }
505            }
506            resp
507        }
508    }
509}
510
511/// Configuration passed to the dispatch handler.
512#[derive(Clone)]
513pub struct DispatchConfig {
514    pub region: String,
515    pub account_id: String,
516    /// Whether to cryptographically verify SigV4 signatures on incoming
517    /// requests. Wired through from `--verify-sigv4` /
518    /// `FAKECLOUD_VERIFY_SIGV4`. Off by default.
519    pub verify_sigv4: bool,
520    /// IAM policy evaluation mode. Wired through from `--iam` /
521    /// `FAKECLOUD_IAM`. Defaults to [`IamMode::Off`]. Actual evaluation is
522    /// added in a later batch; today this field is plumbed but never
523    /// consulted.
524    pub iam_mode: IamMode,
525    /// Resolves access key IDs to their secrets and owning principals.
526    /// Required when `verify_sigv4` or `iam_mode != Off`. When `None`, both
527    /// features gracefully degrade to off-by-default behavior.
528    pub credential_resolver: Option<Arc<dyn CredentialResolver>>,
529    /// Evaluates IAM identity policies for a resolved principal + action.
530    /// Required when `iam_mode != Off`. When `None`, enforcement silently
531    /// degrades to off even if `iam_mode` is set.
532    pub policy_evaluator: Option<Arc<dyn IamPolicyEvaluator>>,
533    /// Resolves resource-based policies (S3 bucket policies in the
534    /// initial rollout) to hand to the evaluator alongside the
535    /// principal's identity policies. `None` means the server was
536    /// started without any resource-policy-owning service registered;
537    /// dispatch then behaves as if no resource policy is attached to
538    /// any resource, identical to the Phase 1 behavior.
539    pub resource_policy_provider: Option<Arc<dyn ResourcePolicyProvider>>,
540    /// Resolves the ordered SCP chain that applies to a principal's
541    /// account (root-OU first, account-direct last). `None` means no
542    /// organizations resolver has been registered — SCPs never gate
543    /// any request in that case. Off-by-default matches the Batch 4
544    /// contract: zero behavior change until a user calls
545    /// `CreateOrganization` and the resolver is wired.
546    pub scp_resolver: Option<Arc<dyn crate::auth::ScpResolver>>,
547}
548
549impl std::fmt::Debug for DispatchConfig {
550    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
551        f.debug_struct("DispatchConfig")
552            .field("region", &self.region)
553            .field("account_id", &self.account_id)
554            .field("verify_sigv4", &self.verify_sigv4)
555            .field("iam_mode", &self.iam_mode)
556            .field(
557                "credential_resolver",
558                &self
559                    .credential_resolver
560                    .as_ref()
561                    .map(|_| "<CredentialResolver>"),
562            )
563            .field(
564                "policy_evaluator",
565                &self
566                    .policy_evaluator
567                    .as_ref()
568                    .map(|_| "<IamPolicyEvaluator>"),
569            )
570            .field(
571                "resource_policy_provider",
572                &self
573                    .resource_policy_provider
574                    .as_ref()
575                    .map(|_| "<ResourcePolicyProvider>"),
576            )
577            .field(
578                "scp_resolver",
579                &self.scp_resolver.as_ref().map(|_| "<ScpResolver>"),
580            )
581            .finish()
582    }
583}
584
585impl DispatchConfig {
586    /// Minimal constructor for tests and call sites that don't care about the
587    /// opt-in security features.
588    pub fn new(region: impl Into<String>, account_id: impl Into<String>) -> Self {
589        Self {
590            region: region.into(),
591            account_id: account_id.into(),
592            verify_sigv4: false,
593            iam_mode: IamMode::Off,
594            credential_resolver: None,
595            policy_evaluator: None,
596            resource_policy_provider: None,
597            scp_resolver: None,
598        }
599    }
600}
601
602/// Extract the 12-digit account ID segment from an AWS ARN.
603///
604/// ARNs follow `arn:<partition>:<service>:<region>:<account>:<resource>`.
605/// For the cross-account decision in IAM enforcement, the "resource
606/// account" is the ARN's account segment. Some services (notably S3)
607/// produce ARNs with an empty account field — for those we return
608/// `None` and let the caller fall back to the server's configured
609/// account ID. Malformed or non-ARN strings also return `None`.
610fn parse_account_from_arn(arn: &str) -> Option<String> {
611    let mut parts = arn.splitn(6, ':');
612    if parts.next()? != "arn" {
613        return None;
614    }
615    let _partition = parts.next()?;
616    let _service = parts.next()?;
617    let _region = parts.next()?;
618    let account = parts.next()?;
619    // Resource segment must exist (parts.next().is_some()) for the ARN
620    // to be well-formed, but we don't consume its value here.
621    parts.next()?;
622    if account.is_empty() {
623        None
624    } else {
625        Some(account.to_string())
626    }
627}
628
629/// Extract region from User-Agent header suffix `region/<region>`.
630fn extract_region_from_user_agent(headers: &http::HeaderMap) -> Option<String> {
631    let ua = headers.get("user-agent")?.to_str().ok()?;
632    for part in ua.split_whitespace() {
633        if let Some(region) = part.strip_prefix("region/") {
634            if !region.is_empty() {
635                return Some(region.to_string());
636            }
637        }
638    }
639    None
640}
641
642fn build_error_response(
643    status: StatusCode,
644    code: &str,
645    message: &str,
646    request_id: &str,
647    protocol: AwsProtocol,
648) -> Response<Body> {
649    build_error_response_with_fields(status, code, message, request_id, protocol, &[])
650}
651
652fn build_error_response_with_fields(
653    status: StatusCode,
654    code: &str,
655    message: &str,
656    request_id: &str,
657    protocol: AwsProtocol,
658    extra_fields: &[(String, String)],
659) -> Response<Body> {
660    let (status, content_type, body) = match protocol {
661        AwsProtocol::Query => {
662            fakecloud_aws::error::xml_error_response(status, code, message, request_id)
663        }
664        AwsProtocol::Rest => fakecloud_aws::error::s3_xml_error_response_with_fields(
665            status,
666            code,
667            message,
668            request_id,
669            extra_fields,
670        ),
671        AwsProtocol::Json | AwsProtocol::RestJson => {
672            fakecloud_aws::error::json_error_response(status, code, message)
673        }
674    };
675
676    Response::builder()
677        .status(status)
678        .header("content-type", content_type)
679        .header("x-amzn-requestid", request_id)
680        .header("x-amz-request-id", request_id)
681        .body(Body::from(body))
682        .unwrap()
683}
684
685/// Build the [`ConditionContext`] passed to the IAM evaluator for one
686/// request. Populates the 10 global condition keys from the resolved
687/// principal + the HTTP request. Service-specific keys are deferred to
688/// a follow-up batch and left empty.
689fn build_condition_context(
690    principal: &Principal,
691    remote_addr: Option<SocketAddr>,
692    region: &str,
693    secure_transport: bool,
694) -> ConditionContext {
695    let now = chrono::Utc::now();
696    ConditionContext {
697        aws_username: aws_username_from_principal(principal),
698        aws_userid: Some(principal.user_id.clone()),
699        aws_principal_arn: Some(principal.arn.clone()),
700        aws_principal_account: Some(principal.account_id.clone()),
701        aws_principal_type: Some(principal_type_label(principal.principal_type).to_string()),
702        aws_source_ip: remote_addr.map(|sa| sa.ip()),
703        aws_current_time: Some(now),
704        aws_epoch_time: Some(now.timestamp()),
705        aws_secure_transport: Some(secure_transport),
706        aws_requested_region: Some(region.to_string()),
707        service_keys: Default::default(),
708        resource_tags: None,
709        request_tags: None,
710        principal_tags: None,
711    }
712}
713
714/// `aws:username` is only set for IAM users, matching AWS. For assumed
715/// roles, federated users, root, and unknown principals the key is
716/// absent — operators that reference it without `IfExists` safe-fail.
717fn aws_username_from_principal(principal: &Principal) -> Option<String> {
718    if principal.principal_type != PrincipalType::User {
719        return None;
720    }
721    let after = principal.arn.rsplit_once(":user/").map(|(_, s)| s)?;
722    // Strip any IAM path prefix; bare username is the last segment.
723    Some(after.rsplit('/').next().unwrap_or(after).to_string())
724}
725
726/// AWS's `aws:PrincipalType` uses PascalCase identifiers, distinct from
727/// the lowercase ones [`PrincipalType::as_str`] returns for ARNs.
728fn principal_type_label(t: PrincipalType) -> &'static str {
729    match t {
730        PrincipalType::User => "User",
731        PrincipalType::AssumedRole => "AssumedRole",
732        PrincipalType::FederatedUser => "FederatedUser",
733        PrincipalType::Root => "Account",
734        PrincipalType::Unknown => "Unknown",
735    }
736}
737
738/// Best-effort detection of TLS-terminated requests. Direct HTTPS
739/// connections are not yet supported by the fakecloud server (it speaks
740/// plain HTTP), so the only signal is an `x-forwarded-proto: https`
741/// header set by an upstream proxy. Anything else evaluates to `false`,
742/// which matches the typical local-dev setup.
743fn is_secure_transport(headers: &http::HeaderMap) -> bool {
744    headers
745        .get("x-forwarded-proto")
746        .and_then(|v| v.to_str().ok())
747        .map(|s| s.eq_ignore_ascii_case("https"))
748        .unwrap_or(false)
749}
750
751trait ProtocolExt {
752    fn error_status(&self) -> StatusCode;
753}
754
755impl ProtocolExt for AwsProtocol {
756    fn error_status(&self) -> StatusCode {
757        StatusCode::BAD_REQUEST
758    }
759}
760
761#[cfg(test)]
762mod tests {
763    use super::*;
764
765    #[test]
766    fn dispatch_config_new_defaults_to_off() {
767        let cfg = DispatchConfig::new("us-east-1", "123456789012");
768        assert_eq!(cfg.region, "us-east-1");
769        assert_eq!(cfg.account_id, "123456789012");
770        assert!(!cfg.verify_sigv4);
771        assert_eq!(cfg.iam_mode, IamMode::Off);
772    }
773
774    #[test]
775    fn aws_username_strips_iam_path_for_users() {
776        let p = Principal {
777            arn: "arn:aws:iam::123456789012:user/engineering/alice".into(),
778            user_id: "AIDAALICE".into(),
779            account_id: "123456789012".into(),
780            principal_type: PrincipalType::User,
781            source_identity: None,
782            tags: None,
783        };
784        assert_eq!(aws_username_from_principal(&p), Some("alice".into()));
785    }
786
787    #[test]
788    fn aws_username_unset_for_assumed_role() {
789        let p = Principal {
790            arn: "arn:aws:sts::123456789012:assumed-role/ops/session".into(),
791            user_id: "AROAOPS:session".into(),
792            account_id: "123456789012".into(),
793            principal_type: PrincipalType::AssumedRole,
794            source_identity: None,
795            tags: None,
796        };
797        assert_eq!(aws_username_from_principal(&p), None);
798    }
799
800    #[test]
801    fn principal_type_label_matches_aws_casing() {
802        assert_eq!(principal_type_label(PrincipalType::User), "User");
803        assert_eq!(
804            principal_type_label(PrincipalType::AssumedRole),
805            "AssumedRole"
806        );
807        assert_eq!(principal_type_label(PrincipalType::Root), "Account");
808    }
809
810    #[test]
811    fn build_condition_context_populates_global_keys() {
812        let p = Principal {
813            arn: "arn:aws:iam::123456789012:user/alice".into(),
814            user_id: "AIDAALICE".into(),
815            account_id: "123456789012".into(),
816            principal_type: PrincipalType::User,
817            source_identity: None,
818            tags: None,
819        };
820        let addr: SocketAddr = "10.0.0.1:54321".parse().unwrap();
821        let ctx = build_condition_context(&p, Some(addr), "us-east-1", false);
822        assert_eq!(ctx.aws_username.as_deref(), Some("alice"));
823        assert_eq!(ctx.aws_userid.as_deref(), Some("AIDAALICE"));
824        assert_eq!(
825            ctx.aws_principal_arn.as_deref(),
826            Some("arn:aws:iam::123456789012:user/alice")
827        );
828        assert_eq!(ctx.aws_principal_account.as_deref(), Some("123456789012"));
829        assert_eq!(ctx.aws_principal_type.as_deref(), Some("User"));
830        assert_eq!(
831            ctx.aws_source_ip.map(|i| i.to_string()).as_deref(),
832            Some("10.0.0.1")
833        );
834        assert_eq!(ctx.aws_requested_region.as_deref(), Some("us-east-1"));
835        assert_eq!(ctx.aws_secure_transport, Some(false));
836        assert!(ctx.aws_current_time.is_some());
837        assert!(ctx.aws_epoch_time.is_some());
838    }
839
840    #[test]
841    fn is_secure_transport_reads_x_forwarded_proto() {
842        let mut headers = http::HeaderMap::new();
843        headers.insert("x-forwarded-proto", "https".parse().unwrap());
844        assert!(is_secure_transport(&headers));
845        headers.insert("x-forwarded-proto", "http".parse().unwrap());
846        assert!(!is_secure_transport(&headers));
847        let empty = http::HeaderMap::new();
848        assert!(!is_secure_transport(&empty));
849    }
850
851    #[test]
852    fn parse_account_from_arn_extracts_standard_shapes() {
853        assert_eq!(
854            parse_account_from_arn("arn:aws:sqs:us-east-1:123456789012:queue"),
855            Some("123456789012".to_string())
856        );
857        assert_eq!(
858            parse_account_from_arn("arn:aws:iam::123456789012:user/alice"),
859            Some("123456789012".to_string())
860        );
861    }
862
863    #[test]
864    fn parse_account_from_arn_returns_none_for_s3_empty_account() {
865        // S3 ARNs have both region and account empty.
866        assert_eq!(parse_account_from_arn("arn:aws:s3:::my-bucket"), None);
867        assert_eq!(
868            parse_account_from_arn("arn:aws:s3:::my-bucket/path/to/key"),
869            None
870        );
871    }
872
873    #[test]
874    fn parse_account_from_arn_returns_none_for_malformed() {
875        assert_eq!(parse_account_from_arn(""), None);
876        assert_eq!(parse_account_from_arn("not-an-arn"), None);
877        assert_eq!(parse_account_from_arn("arn:aws:sqs:us-east-1"), None);
878        assert_eq!(parse_account_from_arn("arn:aws:sqs"), None);
879    }
880
881    #[test]
882    fn extract_region_from_user_agent_finds_region_segment() {
883        let mut headers = http::HeaderMap::new();
884        headers.insert(
885            "user-agent",
886            "aws-sdk-rust/1.0 os/linux region/eu-central-1"
887                .parse()
888                .unwrap(),
889        );
890        assert_eq!(
891            extract_region_from_user_agent(&headers),
892            Some("eu-central-1".to_string())
893        );
894    }
895
896    #[test]
897    fn extract_region_from_user_agent_none_without_header() {
898        let headers = http::HeaderMap::new();
899        assert_eq!(extract_region_from_user_agent(&headers), None);
900    }
901
902    #[test]
903    fn extract_region_from_user_agent_ignores_empty_region() {
904        let mut headers = http::HeaderMap::new();
905        headers.insert("user-agent", "aws-sdk-java region/".parse().unwrap());
906        assert_eq!(extract_region_from_user_agent(&headers), None);
907    }
908
909    #[test]
910    fn extract_region_from_user_agent_none_when_no_region_marker() {
911        let mut headers = http::HeaderMap::new();
912        headers.insert("user-agent", "curl/7.79.1".parse().unwrap());
913        assert_eq!(extract_region_from_user_agent(&headers), None);
914    }
915
916    #[test]
917    fn aws_username_none_for_root() {
918        let p = Principal {
919            arn: "arn:aws:iam::123456789012:root".into(),
920            user_id: "123456789012".into(),
921            account_id: "123456789012".into(),
922            principal_type: PrincipalType::Root,
923            source_identity: None,
924            tags: None,
925        };
926        assert_eq!(aws_username_from_principal(&p), None);
927    }
928
929    #[test]
930    fn aws_username_bare_no_path() {
931        let p = Principal {
932            arn: "arn:aws:iam::123456789012:user/bob".into(),
933            user_id: "AIDABOB".into(),
934            account_id: "123456789012".into(),
935            principal_type: PrincipalType::User,
936            source_identity: None,
937            tags: None,
938        };
939        assert_eq!(aws_username_from_principal(&p), Some("bob".into()));
940    }
941
942    #[test]
943    fn principal_type_label_covers_federated_and_unknown() {
944        assert_eq!(
945            principal_type_label(PrincipalType::FederatedUser),
946            "FederatedUser"
947        );
948        assert_eq!(principal_type_label(PrincipalType::Unknown), "Unknown");
949    }
950
951    #[test]
952    fn build_condition_context_marks_secure_when_flag_set() {
953        let p = Principal {
954            arn: "arn:aws:iam::123456789012:user/alice".into(),
955            user_id: "AIDAALICE".into(),
956            account_id: "123456789012".into(),
957            principal_type: PrincipalType::User,
958            source_identity: None,
959            tags: None,
960        };
961        let ctx = build_condition_context(&p, None, "us-west-2", true);
962        assert_eq!(ctx.aws_secure_transport, Some(true));
963        assert!(ctx.aws_source_ip.is_none());
964        assert_eq!(ctx.aws_requested_region.as_deref(), Some("us-west-2"));
965    }
966
967    #[test]
968    fn is_secure_transport_case_insensitive() {
969        let mut headers = http::HeaderMap::new();
970        headers.insert("x-forwarded-proto", "HTTPS".parse().unwrap());
971        assert!(is_secure_transport(&headers));
972    }
973
974    #[test]
975    fn is_secure_transport_non_ascii_bytes_false() {
976        let mut headers = http::HeaderMap::new();
977        headers.insert(
978            "x-forwarded-proto",
979            http::HeaderValue::from_bytes(&[0xFF, 0xFE]).unwrap(),
980        );
981        assert!(!is_secure_transport(&headers));
982    }
983
984    #[test]
985    fn protocol_ext_error_status_is_bad_request() {
986        assert_eq!(AwsProtocol::Query.error_status(), StatusCode::BAD_REQUEST);
987        assert_eq!(AwsProtocol::Json.error_status(), StatusCode::BAD_REQUEST);
988        assert_eq!(AwsProtocol::Rest.error_status(), StatusCode::BAD_REQUEST);
989        assert_eq!(
990            AwsProtocol::RestJson.error_status(),
991            StatusCode::BAD_REQUEST
992        );
993    }
994
995    #[test]
996    fn build_error_response_json_has_json_content_type() {
997        let resp = build_error_response(
998            StatusCode::BAD_REQUEST,
999            "TestCode",
1000            "test msg",
1001            "req-1",
1002            AwsProtocol::Json,
1003        );
1004        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1005        let ct = resp
1006            .headers()
1007            .get("content-type")
1008            .unwrap()
1009            .to_str()
1010            .unwrap();
1011        assert!(ct.contains("json"));
1012        let rid = resp
1013            .headers()
1014            .get("x-amzn-requestid")
1015            .unwrap()
1016            .to_str()
1017            .unwrap();
1018        assert_eq!(rid, "req-1");
1019    }
1020
1021    #[test]
1022    fn build_error_response_rest_returns_xml_content_type() {
1023        let resp = build_error_response(
1024            StatusCode::NOT_FOUND,
1025            "NoSuchBucket",
1026            "bucket missing",
1027            "req-2",
1028            AwsProtocol::Rest,
1029        );
1030        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
1031        let ct = resp
1032            .headers()
1033            .get("content-type")
1034            .unwrap()
1035            .to_str()
1036            .unwrap();
1037        assert!(ct.contains("xml"));
1038    }
1039
1040    #[test]
1041    fn build_error_response_query_returns_xml() {
1042        let resp = build_error_response(
1043            StatusCode::BAD_REQUEST,
1044            "InvalidParameter",
1045            "bad param",
1046            "req-3",
1047            AwsProtocol::Query,
1048        );
1049        let ct = resp
1050            .headers()
1051            .get("content-type")
1052            .unwrap()
1053            .to_str()
1054            .unwrap();
1055        assert!(ct.contains("xml"));
1056    }
1057
1058    #[test]
1059    fn dispatch_config_carries_opt_in_flags() {
1060        let cfg = DispatchConfig {
1061            region: "eu-west-1".to_string(),
1062            account_id: "000000000000".to_string(),
1063            verify_sigv4: true,
1064            iam_mode: IamMode::Strict,
1065            credential_resolver: None,
1066            policy_evaluator: None,
1067            resource_policy_provider: None,
1068            scp_resolver: None,
1069        };
1070        assert!(cfg.verify_sigv4);
1071        assert!(cfg.iam_mode.is_strict());
1072        assert!(cfg.resource_policy_provider.is_none());
1073        assert!(cfg.scp_resolver.is_none());
1074    }
1075}