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