diff --git a/README.md b/README.md index d67498ee3..8722ee501 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ Works as a drop-in for LocalStack in CI, with Terraform (`endpoints` block), CDK - **Single binary.** ~19 MB, ~10 MiB idle, ~300ms startup. No Docker needed to run fakecloud itself. - **Full Bedrock surface.** 216 ops across 4 APIs with real `InvokeModel`/`Converse` streaming, guardrails, agents, and flows. Configurable responses + fault injection for deterministic tests. See [`/bedrock-emulator/`](https://fakecloud.dev/bedrock-emulator/). - **First-party test SDKs** for TypeScript, Python, Go, PHP, Java, and Rust. Assert on what your code called without raw HTTP. -- **Opt-in SigV4 verification and IAM enforcement.** Off by default so tests just work; `--verify-sigv4` for real signature checking and `--iam soft|strict` for policy evaluation across IAM/STS/SQS/SNS/S3. See [security docs](https://fakecloud.dev/docs/reference/security/). +- **Opt-in SigV4 verification and IAM enforcement.** Off by default so tests just work; `--verify-sigv4` for real signature checking and `--iam soft|strict` for policy evaluation across IAM, STS, SQS, SNS, S3, KMS, Lambda, DynamoDB (and Streams), ELBv2 and Scheduler. See [security docs](https://fakecloud.dev/docs/reference/security/). - **Run your app unmodified.** An app that expects an instance/task role resolves the AWS SDK default credential chain against fakecloud with no static keys and no code change: point `AWS_CONTAINER_CREDENTIALS_FULL_URI` at `/_fakecloud/credentials`. See [Run an app unmodified](https://fakecloud.dev/docs/guides/instance-credentials/). - **LocalStack and real-AWS URL compatibility.** Both `*.localhost.localstack.cloud` and `*.amazonaws.com` Host headers route correctly, including every S3 virtual-hosted variant. Persisted URLs and dev scripts from either system replay unchanged. diff --git a/crates/fakecloud-core/src/dispatch.rs b/crates/fakecloud-core/src/dispatch.rs index 83f66a821..d72fb5855 100644 --- a/crates/fakecloud-core/src/dispatch.rs +++ b/crates/fakecloud-core/src/dispatch.rs @@ -592,166 +592,173 @@ pub async fn dispatch( if let Some(evaluator) = config.policy_evaluator.as_ref() { if let Some(principal) = aws_request.principal.as_ref() { if !principal.is_root() { - if let Some(iam_action) = service.iam_action_for(&aws_request) { - let mut condition_context = build_condition_context( - principal, - remote_addr, - &aws_request.region, - is_secure_transport(&aws_request.headers), - ); - // F3 keys riding on the resolved credential. STS - // populates these at mint time so subsequent - // requests under the credential can be evaluated - // against `aws:MultiFactorAuthPresent`, - // `aws:MultiFactorAuthAge`, `aws:TokenIssueTime`, - // and `aws:FederatedProvider`. IAM user access - // keys carry none of these, matching AWS. - if let Some(rc) = resolved.as_ref() { - condition_context.aws_mfa_present = Some(rc.mfa_present); - condition_context.aws_token_issue_time = rc.token_issued_at; - condition_context.aws_federated_provider = - rc.federated_provider.clone(); - // `aws:MultiFactorAuthAge` is "seconds since - // MFA was asserted" — computed at evaluation - // time from the token issue moment so the - // value increases monotonically as the session - // ages. Only set when the session was actually - // minted with MFA; otherwise the key is - // absent, matching AWS. - if rc.mfa_present { - if let Some(issued) = rc.token_issued_at { - let age = chrono::Utc::now() - .signed_duration_since(issued) - .num_seconds() - .max(0); - condition_context.aws_mfa_age_seconds = Some(age); + // A request can need several authorizations -- one per + // table in a batch, say -- and every one must allow it. + let iam_actions = service.iam_actions_for(&aws_request); + if !iam_actions.is_empty() { + for iam_action in &iam_actions { + let mut condition_context = build_condition_context( + principal, + remote_addr, + &aws_request.region, + is_secure_transport(&aws_request.headers), + ); + // F3 keys riding on the resolved credential. STS + // populates these at mint time so subsequent + // requests under the credential can be evaluated + // against `aws:MultiFactorAuthPresent`, + // `aws:MultiFactorAuthAge`, `aws:TokenIssueTime`, + // and `aws:FederatedProvider`. IAM user access + // keys carry none of these, matching AWS. + if let Some(rc) = resolved.as_ref() { + condition_context.aws_mfa_present = Some(rc.mfa_present); + condition_context.aws_token_issue_time = rc.token_issued_at; + condition_context.aws_federated_provider = + rc.federated_provider.clone(); + // `aws:MultiFactorAuthAge` is "seconds since + // MFA was asserted" — computed at evaluation + // time from the token issue moment so the + // value increases monotonically as the session + // ages. Only set when the session was actually + // minted with MFA; otherwise the key is + // absent, matching AWS. + if rc.mfa_present { + if let Some(issued) = rc.token_issued_at { + let age = chrono::Utc::now() + .signed_duration_since(issued) + .num_seconds() + .max(0); + condition_context.aws_mfa_age_seconds = Some(age); + } } } - } - condition_context.service_keys = - service.iam_condition_keys_for(&aws_request, &iam_action); - - // ABAC: populate tag-based condition keys. - // aws:ResourceTag/* - match service.resource_tags_for(&iam_action.resource) { - Some(tags) => condition_context.resource_tags = Some(tags), - None => tracing::debug!( - target: "fakecloud::iam::audit", - service = %detected.service, - resource = %iam_action.resource, - "service does not expose resource tags for ABAC; skipping aws:ResourceTag/* evaluation" - ), - } - // aws:RequestTag/* + aws:TagKeys - match service.request_tags_from(&aws_request, iam_action.action) { - Some(tags) => condition_context.request_tags = Some(tags), - None => tracing::debug!( - target: "fakecloud::iam::audit", - service = %detected.service, - action = %iam_action.action_string(), - "service does not expose request tags for ABAC; skipping aws:RequestTag/* / aws:TagKeys evaluation" - ), - } - // aws:PrincipalTag/* - condition_context.principal_tags = principal.tags.clone(); - - // Phase 2: fetch the resource-based policy (if - // any) attached to the target resource and - // pass it to the evaluator alongside the - // principal's identity policies. The resource's - // owning account is parsed from the ARN (#381 - // multi-account alignment); S3 ARNs have an - // empty account field, so we fall back to the - // server's configured account ID in that case. - let resource_policy_json = - config.resource_policy_provider.as_ref().and_then(|p| { - p.resource_policy(&detected.service, &iam_action.resource) - }); - // Derive the resource-owning account. Prefer a provider - // lookup (S3 ARNs carry no account, so the bucket's - // owner is resolved from state — without this, account - // A reaching account B's bucket would be mis-read as - // same-account and skip B's bucket-policy requirement, - // bug-audit 2026-05-28, 5.3), then fall back to the - // account embedded in the ARN (SQS/SNS/Lambda/…), then - // to the caller's account for wildcard / unscoped - // actions (ListQueues, GetCallerIdentity). - let resource_account_id = config - .resource_policy_provider - .as_ref() - .and_then(|p| { - p.resource_owner_account(&detected.service, &iam_action.resource) - }) - .or_else(|| parse_account_from_arn(&iam_action.resource)) - .unwrap_or_else(|| principal.account_id.clone()); - // SCP ceiling: resolve the inherited SCP chain - // for this principal (management accounts and - // service-linked roles come back as `None`, in - // which case the evaluator treats the layer as - // absent). Audit breadcrumbs emitted by the - // resolver itself, not here. - let scps = config - .scp_resolver - .as_ref() - .and_then(|r| r.scps_for(principal)); - let decision = evaluator.evaluate_with_resource_policy( - principal, - &iam_action, - &condition_context, - resource_policy_json.as_deref(), - &resource_account_id, - &caller_session_policies, - scps.as_deref(), - ); - if !decision.is_allow() { - tracing::warn!( - target: "fakecloud::iam::audit", - service = %detected.service, - action = %iam_action.action_string(), - resource = %iam_action.resource, - principal = %principal.arn, - resource_policy_present = resource_policy_json.is_some(), - decision = ?decision, - mode = %config.iam_mode, - request_id = %request_id, - "IAM policy evaluation denied request" - ); - if config.iam_mode.is_strict() { - // Real AWS includes an "Encoded - // authorization failure message" suffix - // on AccessDeniedException — an opaque - // base64+zlib JSON blob that the caller - // can pass to STS - // `DecodeAuthorizationMessage` to - // recover the structured deny reason - // (action, principal, matched - // statements, condition context). We - // produce the same blob inline so - // existing tooling that decodes deny - // reasons works against fakecloud. - let context_summary = serde_json::json!({ - "aws:PrincipalArn": principal.arn, - "aws:PrincipalAccount": principal.account_id, - "aws:RequestedRegion": condition_context - .aws_requested_region - .clone() - .unwrap_or_default(), - "aws:SecureTransport": condition_context - .aws_secure_transport - .unwrap_or(false), - "aws:Action": iam_action.action_string(), - "aws:Resource": iam_action.resource, - "decision": format!("{:?}", decision), + condition_context.service_keys = + service.iam_condition_keys_for(&aws_request, iam_action); + + // ABAC: populate tag-based condition keys. + // aws:ResourceTag/* + match service.resource_tags_for(&iam_action.resource) { + Some(tags) => condition_context.resource_tags = Some(tags), + None => tracing::debug!( + target: "fakecloud::iam::audit", + service = %detected.service, + resource = %iam_action.resource, + "service does not expose resource tags for ABAC; skipping aws:ResourceTag/* evaluation" + ), + } + // aws:RequestTag/* + aws:TagKeys + match service.request_tags_from(&aws_request, iam_action.action) { + Some(tags) => condition_context.request_tags = Some(tags), + None => tracing::debug!( + target: "fakecloud::iam::audit", + service = %detected.service, + action = %iam_action.action_string(), + "service does not expose request tags for ABAC; skipping aws:RequestTag/* / aws:TagKeys evaluation" + ), + } + // aws:PrincipalTag/* + condition_context.principal_tags = principal.tags.clone(); + + // Phase 2: fetch the resource-based policy (if + // any) attached to the target resource and + // pass it to the evaluator alongside the + // principal's identity policies. The resource's + // owning account is parsed from the ARN (#381 + // multi-account alignment); S3 ARNs have an + // empty account field, so we fall back to the + // server's configured account ID in that case. + let resource_policy_json = + config.resource_policy_provider.as_ref().and_then(|p| { + p.resource_policy(&detected.service, &iam_action.resource) }); - let action_string = iam_action.action_string(); - let encoded = crate::auth_message::encode_deny( - matches!(decision, crate::auth::IamDecision::ExplicitDeny), - Some(&action_string), - Some(&principal.arn), - Vec::new(), - Some(context_summary), + // Derive the resource-owning account. Prefer a provider + // lookup (S3 ARNs carry no account, so the bucket's + // owner is resolved from state — without this, account + // A reaching account B's bucket would be mis-read as + // same-account and skip B's bucket-policy requirement, + // bug-audit 2026-05-28, 5.3), then fall back to the + // account embedded in the ARN (SQS/SNS/Lambda/…), then + // to the caller's account for wildcard / unscoped + // actions (ListQueues, GetCallerIdentity). + let resource_account_id = config + .resource_policy_provider + .as_ref() + .and_then(|p| { + p.resource_owner_account( + &detected.service, + &iam_action.resource, + ) + }) + .or_else(|| parse_account_from_arn(&iam_action.resource)) + .unwrap_or_else(|| principal.account_id.clone()); + // SCP ceiling: resolve the inherited SCP chain + // for this principal (management accounts and + // service-linked roles come back as `None`, in + // which case the evaluator treats the layer as + // absent). Audit breadcrumbs emitted by the + // resolver itself, not here. + let scps = config + .scp_resolver + .as_ref() + .and_then(|r| r.scps_for(principal)); + let decision = evaluator.evaluate_with_resource_policy( + principal, + iam_action, + &condition_context, + resource_policy_json.as_deref(), + &resource_account_id, + &caller_session_policies, + scps.as_deref(), + ); + if !decision.is_allow() { + tracing::warn!( + target: "fakecloud::iam::audit", + service = %detected.service, + action = %iam_action.action_string(), + resource = %iam_action.resource, + principal = %principal.arn, + resource_policy_present = resource_policy_json.is_some(), + decision = ?decision, + mode = %config.iam_mode, + request_id = %request_id, + "IAM policy evaluation denied request" ); - return build_error_response( + if config.iam_mode.is_strict() { + // Real AWS includes an "Encoded + // authorization failure message" suffix + // on AccessDeniedException — an opaque + // base64+zlib JSON blob that the caller + // can pass to STS + // `DecodeAuthorizationMessage` to + // recover the structured deny reason + // (action, principal, matched + // statements, condition context). We + // produce the same blob inline so + // existing tooling that decodes deny + // reasons works against fakecloud. + let context_summary = serde_json::json!({ + "aws:PrincipalArn": principal.arn, + "aws:PrincipalAccount": principal.account_id, + "aws:RequestedRegion": condition_context + .aws_requested_region + .clone() + .unwrap_or_default(), + "aws:SecureTransport": condition_context + .aws_secure_transport + .unwrap_or(false), + "aws:Action": iam_action.action_string(), + "aws:Resource": iam_action.resource, + "decision": format!("{:?}", decision), + }); + let action_string = iam_action.action_string(); + let encoded = crate::auth_message::encode_deny( + matches!(decision, crate::auth::IamDecision::ExplicitDeny), + Some(&action_string), + Some(&principal.arn), + Vec::new(), + Some(context_summary), + ); + return build_error_response( StatusCode::FORBIDDEN, "AccessDeniedException", &format!( @@ -764,9 +771,10 @@ pub async fn dispatch( &request_id, detected.protocol, ); + } + // Soft mode: audit log already emitted; fall + // through to the handler. } - // Soft mode: audit log already emitted; fall - // through to the handler. } } else { // Service opted in via `iam_enforceable()` but its @@ -820,63 +828,66 @@ pub async fn dispatch( // SigV4 verification off, fakecloud does not reject unverified // signed requests, and turning them into anonymous denials would // change long-standing behavior. - if let Some(iam_action) = service.iam_action_for(&aws_request) { - let now = chrono::Utc::now(); - let mut condition_context = ConditionContext { - aws_source_ip: remote_addr.map(|sa| sa.ip()), - aws_current_time: Some(now), - aws_epoch_time: Some(now.timestamp()), - aws_secure_transport: Some(is_secure_transport(&aws_request.headers)), - aws_requested_region: Some(aws_request.region.clone()), - ..Default::default() - }; - condition_context.service_keys = - service.iam_condition_keys_for(&aws_request, &iam_action); - let resource_policy_json = config - .resource_policy_provider - .as_ref() - .and_then(|p| p.resource_policy(&detected.service, &iam_action.resource)); - let policy_decision = evaluator.evaluate_anonymous( - &iam_action, - &condition_context, - resource_policy_json.as_deref(), - ); - let policy_allows = policy_decision.is_allow(); - // An explicit Deny in the resource policy always wins, even - // over a public-read ACL — matching AWS's Deny-overrides - // precedence. Collapsing the decision to a bool and ORing the - // ACL let a public ACL override an explicit anonymous Deny. - let policy_explicit_deny = - matches!(policy_decision, crate::auth::IamDecision::ExplicitDeny); - let acl_allows = !policy_explicit_deny - && config.resource_policy_provider.as_ref().is_some_and(|p| { - p.public_acl_allows( - &detected.service, - &iam_action.resource, - iam_action.action, - ) - }); - if !policy_allows && !acl_allows { - tracing::warn!( - target: "fakecloud::iam::audit", - service = %detected.service, - action = %iam_action.action_string(), - resource = %iam_action.resource, - resource_policy_present = resource_policy_json.is_some(), - mode = %config.iam_mode, - request_id = %request_id, - "anonymous request denied: no public bucket policy or ACL grants the action" + let iam_actions = service.iam_actions_for(&aws_request); + if !iam_actions.is_empty() { + for iam_action in &iam_actions { + let now = chrono::Utc::now(); + let mut condition_context = ConditionContext { + aws_source_ip: remote_addr.map(|sa| sa.ip()), + aws_current_time: Some(now), + aws_epoch_time: Some(now.timestamp()), + aws_secure_transport: Some(is_secure_transport(&aws_request.headers)), + aws_requested_region: Some(aws_request.region.clone()), + ..Default::default() + }; + condition_context.service_keys = + service.iam_condition_keys_for(&aws_request, iam_action); + let resource_policy_json = + config.resource_policy_provider.as_ref().and_then(|p| { + p.resource_policy(&detected.service, &iam_action.resource) + }); + let policy_decision = evaluator.evaluate_anonymous( + iam_action, + &condition_context, + resource_policy_json.as_deref(), ); - if config.iam_mode.is_strict() { - return build_error_response( - StatusCode::FORBIDDEN, - "AccessDenied", - "Access Denied", - &request_id, - detected.protocol, + let policy_allows = policy_decision.is_allow(); + // An explicit Deny in the resource policy always wins, even + // over a public-read ACL — matching AWS's Deny-overrides + // precedence. Collapsing the decision to a bool and ORing the + // ACL let a public ACL override an explicit anonymous Deny. + let policy_explicit_deny = + matches!(policy_decision, crate::auth::IamDecision::ExplicitDeny); + let acl_allows = !policy_explicit_deny + && config.resource_policy_provider.as_ref().is_some_and(|p| { + p.public_acl_allows( + &detected.service, + &iam_action.resource, + iam_action.action, + ) + }); + if !policy_allows && !acl_allows { + tracing::warn!( + target: "fakecloud::iam::audit", + service = %detected.service, + action = %iam_action.action_string(), + resource = %iam_action.resource, + resource_policy_present = resource_policy_json.is_some(), + mode = %config.iam_mode, + request_id = %request_id, + "anonymous request denied: no public bucket policy or ACL grants the action" ); + if config.iam_mode.is_strict() { + return build_error_response( + StatusCode::FORBIDDEN, + "AccessDenied", + "Access Denied", + &request_id, + detected.protocol, + ); + } + // Soft mode: audit log emitted; fall through to the handler. } - // Soft mode: audit log emitted; fall through to the handler. } } else { // Anonymous request to an iam_enforceable service whose diff --git a/crates/fakecloud-core/src/service.rs b/crates/fakecloud-core/src/service.rs index ff002620a..584296153 100644 --- a/crates/fakecloud-core/src/service.rs +++ b/crates/fakecloud-core/src/service.rs @@ -747,6 +747,20 @@ pub trait AwsService: Send + Sync { None } + /// Every IAM authorization an incoming request needs. + /// + /// Most operations act on one resource and need one action, which is + /// what the default returns ([`AwsService::iam_action_for`]). Some need + /// several: a batch or transaction naming several resources needs the + /// action on each, and an operation can require more than one action + /// (DynamoDB's `CreateTable` with `Tags` also needs `TagResource`). + /// Dispatch evaluates every action returned and denies the request if + /// any of them is denied. An empty list means the operation has no + /// mapping, which strict enforcement denies. + fn iam_actions_for(&self, request: &AwsRequest) -> Vec { + self.iam_action_for(request).into_iter().collect() + } + /// Derive service-specific IAM condition keys for an incoming request. /// /// Called right after [`AwsService::iam_action_for`] when IAM diff --git a/crates/fakecloud-dynamodb/src/lib.rs b/crates/fakecloud-dynamodb/src/lib.rs index 68b65d2af..85a9b6e8a 100644 --- a/crates/fakecloud-dynamodb/src/lib.rs +++ b/crates/fakecloud-dynamodb/src/lib.rs @@ -1,4 +1,5 @@ pub mod export_import; +pub mod resource_policy; pub(crate) mod service; pub(crate) mod state; pub mod streams; @@ -6,6 +7,7 @@ pub mod streams_dataplane; pub mod ttl; pub use export_import::{import_aws_export, import_aws_exports_dir, ImportOutcome}; +pub use resource_policy::DynamoDbResourcePolicyProvider; pub(crate) use service::helpers::schemas::{ parse_attribute_definitions, parse_key_schema, parse_provisioned_throughput, }; diff --git a/crates/fakecloud-dynamodb/src/resource_policy.rs b/crates/fakecloud-dynamodb/src/resource_policy.rs new file mode 100644 index 000000000..6cd1e1a08 --- /dev/null +++ b/crates/fakecloud-dynamodb/src/resource_policy.rs @@ -0,0 +1,144 @@ +//! DynamoDB implementation of [`ResourcePolicyProvider`]: the policy IAM +//! enforcement evaluates alongside the caller's identity policies. +//! +//! A table's policy also governs its indexes (AWS has no separate index +//! policy); a stream has a policy of its own. Backups, exports and imports +//! cannot carry resource-based policies. + +use std::sync::Arc; + +use fakecloud_core::auth::ResourcePolicyProvider; + +use crate::state::SharedDynamoDbState; + +pub struct DynamoDbResourcePolicyProvider { + state: SharedDynamoDbState, +} + +impl DynamoDbResourcePolicyProvider { + pub fn new(state: SharedDynamoDbState) -> Self { + Self { state } + } + + /// Convenience constructor for server bootstrap's + /// `MultiResourcePolicyProvider`. + pub fn shared(state: SharedDynamoDbState) -> Arc { + Arc::new(Self::new(state)) + } +} + +/// The account, table name and sub-resource path of a DynamoDB table-scoped +/// ARN: `arn:aws:dynamodb:REGION:ACCOUNT:table/NAME[/KIND/ID]`. +fn parse(arn: &str) -> Option<(&str, &str, Option<&str>)> { + let rest = arn.strip_prefix("arn:aws:dynamodb:")?; + let (scope, path) = rest.split_once(":table/")?; + let account = scope.split(':').nth(1).filter(|a| !a.is_empty())?; + let (name, sub) = match path.split_once('/') { + Some((name, sub)) => (name, Some(sub)), + None => (path, None), + }; + (!name.is_empty()).then_some((account, name, sub)) +} + +fn is_dynamodb(service: &str) -> bool { + service.eq_ignore_ascii_case("dynamodb") || service.eq_ignore_ascii_case("dynamodbstreams") +} + +impl ResourcePolicyProvider for DynamoDbResourcePolicyProvider { + fn resource_policy(&self, service: &str, resource_arn: &str) -> Option { + if !is_dynamodb(service) { + return None; + } + let (account, name, sub) = parse(resource_arn)?; + let accounts = self.state.read(); + let state = accounts.get(account)?; + let table = state.tables.get(name)?; + match sub { + None => table.resource_policy.clone(), + Some(sub) if sub.starts_with("index/") => table.resource_policy.clone(), + Some(sub) if sub.starts_with("stream/") => { + state.stream_policies.get(resource_arn).cloned() + } + Some(_) => None, + } + } + + fn resource_owner_account(&self, service: &str, resource_arn: &str) -> Option { + if !is_dynamodb(service) { + return None; + } + parse(resource_arn).map(|(account, _, _)| account.to_string()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::state::{DynamoDbState, DynamoTable, KeySchemaElement, ProvisionedThroughput}; + use fakecloud_core::multi_account::MultiAccountState; + + const ARN: &str = "arn:aws:dynamodb:us-east-1:111122223333:table/Orders"; + + fn provider() -> DynamoDbResourcePolicyProvider { + let mut accounts = MultiAccountState::::new("111122223333", "us-east-1", ""); + let state = accounts.get_or_create("111122223333"); + let mut table = DynamoTable::new( + "Orders".to_string(), + ARN.to_string(), + "id".to_string(), + vec![KeySchemaElement { + attribute_name: "pk".to_string(), + key_type: "HASH".to_string(), + }], + vec![], + ProvisionedThroughput { + read_capacity_units: 1, + write_capacity_units: 1, + }, + "PAY_PER_REQUEST".to_string(), + chrono::Utc::now(), + ); + table.resource_policy = Some("table-policy".to_string()); + state.tables.insert("Orders".to_string(), table); + state + .stream_policies + .insert(format!("{ARN}/stream/label"), "stream-policy".to_string()); + DynamoDbResourcePolicyProvider::new(Arc::new(parking_lot::RwLock::new(accounts))) + } + + #[test] + fn tables_and_indexes_share_the_table_policy_and_streams_have_their_own() { + let p = provider(); + assert_eq!( + p.resource_policy("dynamodb", ARN).as_deref(), + Some("table-policy") + ); + assert_eq!( + p.resource_policy("dynamodb", &format!("{ARN}/index/by-g")) + .as_deref(), + Some("table-policy") + ); + assert_eq!( + p.resource_policy("dynamodbstreams", &format!("{ARN}/stream/label")) + .as_deref(), + Some("stream-policy") + ); + assert_eq!( + p.resource_policy("dynamodb", &format!("{ARN}/stream/other")), + None + ); + assert_eq!( + p.resource_policy("dynamodb", &format!("{ARN}/backup/b")), + None + ); + assert_eq!(p.resource_policy("sqs", ARN), None); + assert_eq!( + p.resource_owner_account( + "dynamodb", + "arn:aws:dynamodb:us-east-1:444455556666:table/X" + ) + .as_deref(), + Some("444455556666") + ); + } +} diff --git a/crates/fakecloud-dynamodb/src/service/batch.rs b/crates/fakecloud-dynamodb/src/service/batch.rs index 07b52d3a8..5c24ac7d9 100644 --- a/crates/fakecloud-dynamodb/src/service/batch.rs +++ b/crates/fakecloud-dynamodb/src/service/batch.rs @@ -1162,7 +1162,7 @@ impl DynamoDbService { let outcome = execute_partiql_in_state(state, statement, ¶meters)?; // ExecuteStatement honors Limit + NextToken on a SELECT result set // (AWS paginates PartiQL SELECTs); the token is an opaque cursor. - let response = apply_execute_statement_pagination( + let mut response = apply_execute_statement_pagination( outcome.response.clone(), outcome .table_name @@ -1171,6 +1171,12 @@ impl DynamoDbService { limit, next_token.as_deref(), ); + // Columns are projected after paging: the cursor is the last + // row's full primary key. + super::helpers::partiql::project_partiql_response( + &mut response, + outcome.projection.as_ref(), + ); let kinesis_info = if let (Some(table_name), Some(event_name)) = (outcome.table_name.as_ref(), outcome.event_name.as_ref()) @@ -1255,9 +1261,14 @@ impl DynamoDbService { match execute_partiql_in_state(state, statement, ¶meters) { Ok(outcome) => { + let mut projected = outcome.response.clone(); + super::helpers::partiql::project_partiql_response( + &mut projected, + outcome.projection.as_ref(), + ); responses.push(batch_partiql_response( statement, - outcome.response.clone(), + projected, outcome .table_name .as_ref() @@ -1478,7 +1489,12 @@ impl DynamoDbService { match execute_partiql_in_state(state, statement, ¶meters) { Ok(outcome) => { - applied_responses.push(outcome.response); + let mut projected = outcome.response; + super::helpers::partiql::project_partiql_response( + &mut projected, + outcome.projection.as_ref(), + ); + applied_responses.push(projected); let table_name = match outcome.table_name { Some(n) => n, None => continue, @@ -3026,15 +3042,18 @@ mod tests { .unwrap(); // A non-ASCII, non-key attribute in the WHERE predicate must not panic - // the RETURNING scan (it simply matches nothing). - svc.execute_statement(&req_for( - "ExecuteStatement", - json!({ - "Statement": "UPDATE \"Widgets\" SET \"x\" = ? WHERE \"café\" = ?", - "Parameters": [{ "S": "1" }, { "S": "z" }] - }), - )) - .unwrap(); + // the RETURNING scan. It is not the key, so DynamoDB rejects it. + let err = svc + .execute_statement(&req_for( + "ExecuteStatement", + json!({ + "Statement": "UPDATE \"Widgets\" SET \"x\" = ? WHERE \"café\" = ?", + "Parameters": [{ "S": "1" }, { "S": "z" }] + }), + )) + .err() + .expect("a WHERE without the key is rejected"); + assert_eq!(err.code(), "ValidationException"); // Read back through a batch single-item SELECT with tight `"pk"=?` // spacing (no surrounding spaces) to confirm the key-check tolerates it. @@ -3070,7 +3089,11 @@ mod tests { )) .unwrap(); - let updated = svc + // Without a WHERE the statement names no item, which DynamoDB rejects; + // the RETURNING clause is still stripped rather than parsed as part of + // the SET expression, so the error is the key check, not a garbled + // update expression. + let err = svc .execute_statement(&req_for( "ExecuteStatement", json!({ @@ -3078,6 +3101,20 @@ mod tests { "Parameters": [{ "S": "on" }] }), )) + .err() + .expect("an UPDATE without a key WHERE is rejected"); + assert_eq!( + err.to_string(), + "ValidationException: Where clause does not contain a mandatory equality on all key attributes" + ); + let updated = svc + .execute_statement(&req_for( + "ExecuteStatement", + json!({ + "Statement": "UPDATE \"Widgets\" SET \"flag\" = ? WHERE \"pk\" = 'a' RETURNING ALL NEW *", + "Parameters": [{ "S": "on" }] + }), + )) .unwrap(); assert_eq!(response_body(&updated)["Item"]["flag"]["S"], "on"); } diff --git a/crates/fakecloud-dynamodb/src/service/helpers/mod.rs b/crates/fakecloud-dynamodb/src/service/helpers/mod.rs index 3deb667bf..fc9fb6743 100644 --- a/crates/fakecloud-dynamodb/src/service/helpers/mod.rs +++ b/crates/fakecloud-dynamodb/src/service/helpers/mod.rs @@ -810,6 +810,10 @@ pub(crate) struct PartiqlOutcome { pub keys: Option>, pub old_image: Option>, pub new_image: Option>, + /// A SELECT's column list as a projection request, applied to the rows + /// the caller returns -- after any pagination, whose cursor needs each + /// row's full primary key. `None` for `*` and for writes. + pub projection: Option, } /// AST for a parsed PartiQL WHERE clause. Leaf conditions reuse diff --git a/crates/fakecloud-dynamodb/src/service/helpers/partiql.rs b/crates/fakecloud-dynamodb/src/service/helpers/partiql.rs index 5160d3a9f..b74ae9729 100644 --- a/crates/fakecloud-dynamodb/src/service/helpers/partiql.rs +++ b/crates/fakecloud-dynamodb/src/service/helpers/partiql.rs @@ -17,15 +17,36 @@ pub(crate) fn find_outside_quotes(hay: &str, needle: &str) -> Option { } let bytes = hay.as_bytes(); let nbytes = needle.as_bytes(); + // A keyword needle (`FROM`, `WHERE`, ...) only matches as a whole word: + // `somewhere` or `fromage` is an identifier that happens to contain it. + let is_word = |b: u8| b.is_ascii_alphanumeric() || b == b'_'; + let keyword = nbytes.iter().all(|b| b.is_ascii_alphabetic()); let mut in_quote = false; + let mut in_dquote = false; let mut i = 0usize; while i < bytes.len() { - if bytes[i] == b'\'' { - in_quote = !in_quote; - i += 1; - continue; + match bytes[i] { + b'\'' if !in_dquote => { + in_quote = !in_quote; + i += 1; + continue; + } + // A double-quoted identifier (`"from"`) is a name, not syntax. + b'"' if !in_quote => { + in_dquote = !in_dquote; + i += 1; + continue; + } + _ => {} } - if !in_quote && i + nbytes.len() <= bytes.len() && &bytes[i..i + nbytes.len()] == nbytes { + if !in_quote + && !in_dquote + && i + nbytes.len() <= bytes.len() + && &bytes[i..i + nbytes.len()] == nbytes + && (!keyword + || ((i == 0 || !is_word(bytes[i - 1])) + && (i + nbytes.len() == bytes.len() || !is_word(bytes[i + nbytes.len()])))) + { return Some(i); } i += 1; @@ -418,18 +439,77 @@ pub(crate) fn execute_partiql_in_state( let after_from = trimmed[from_pos + 4..].trim(); let (table_name, rest) = parse_partiql_table_name(after_from); let table = get_table(&state.tables, &table_name)?; + // `FROM "table"."index"` reads the index: only the rows that carry + // its key attributes, with only the attributes it projects. Ignoring + // the index segment read the whole base table -- and, since the rest + // of the statement then did not start with WHERE, skipped the WHERE + // clause too. + let (index, rest) = match rest.strip_prefix('.') { + Some(index_part) => { + let (index_name, rest) = parse_partiql_table_name(index_part); + let index = table + .gsi + .iter() + .map(|g| (&g.index_name, &g.key_schema, &g.projection)) + .chain( + table + .lsi + .iter() + .map(|l| (&l.index_name, &l.key_schema, &l.projection)), + ) + .find(|(name, _, _)| **name == index_name) + .ok_or_else(|| { + AwsServiceError::aws_error( + StatusCode::BAD_REQUEST, + "ValidationException", + format!("The table does not have the specified index: {index_name}"), + ) + })?; + let key_attrs: Vec = + index.1.iter().map(|k| k.attribute_name.clone()).collect(); + (Some((key_attrs, index.2.clone())), rest) + } + None => (None, rest), + }; let rest_upper = rest.trim().to_ascii_uppercase(); let mut rows: Vec<&HashMap> = if rest_upper.starts_with("WHERE") { let where_clause = rest.trim()[5..].trim(); evaluate_partiql_where(table, where_clause, parameters)? - } else { + } else if rest.trim().is_empty() { table.items.iter().collect() + } else { + return Err(AwsServiceError::aws_error( + StatusCode::BAD_REQUEST, + "ValidationException", + format!("Statement wasn't well formed, can't be processed: {trimmed}"), + )); }; + if let Some((key_attrs, _)) = &index { + rows.retain(|item| key_attrs.iter().all(|k| item.contains_key(k))); + } // Scan order, which ExecuteStatement's NextToken resumes by key: an // order that depends on which rows exist would skip or repeat rows // when some are deleted between pages. table.sort_in_scan_order(&mut rows); - let items: Vec = rows.iter().map(|item| json!(item)).collect(); + // The column list: `*`, or the attributes (document paths) to return. + // It is validated here and applied by the caller to the rows it + // returns. + let projection = partiql_projection(trimmed["SELECT".len()..from_pos].trim())?; + let items: Vec = rows + .iter() + .map(|item| match &index { + Some((key_attrs, projection)) => { + json!(crate::service::queries::apply_index_projection( + (*item).clone(), + projection, + key_attrs, + table.hash_key_name(), + table.range_key_name(), + )) + } + None => json!(item), + }) + .collect(); Ok(PartiqlOutcome { response: json!({ "Items": items }), table_name: Some(table_name), @@ -437,6 +517,7 @@ pub(crate) fn execute_partiql_in_state( keys: None, old_image: None, new_image: None, + projection, }) } else if upper.starts_with("INSERT") { let into_pos = find_outside_quotes(&upper, "INTO").ok_or_else(|| { @@ -479,6 +560,7 @@ pub(crate) fn execute_partiql_in_state( keys: Some(key), old_image: None, new_image: Some(item), + projection: None, }) } else if upper.starts_with("UPDATE") { let after_update = trimmed[6..].trim(); @@ -511,16 +593,16 @@ pub(crate) fn execute_partiql_in_state( // before WHERE, so SET consumes parameters[0..set_count] and WHERE the // rest. Evaluate WHERE against the parameters that follow the SET ones. let set_param_count = count_params_in_str(set_clause); - let matched_indices = if !where_clause.is_empty() { - let where_params: &[Value] = if set_param_count <= parameters.len() { - ¶meters[set_param_count..] - } else { - &[] - }; - find_partiql_where_indices(table, where_clause, where_params)? + let where_params: &[Value] = if set_param_count <= parameters.len() { + ¶meters[set_param_count..] } else { - table.items.iter_with_ids().map(|(id, _)| id).collect() + &[] }; + require_partiql_key_equality( + table, + partiql_where_conditions(where_clause, where_params).as_ref(), + )?; + let matched_indices = find_partiql_where_indices(table, where_clause, where_params)?; let mut last_key: Option> = None; let mut last_old: Option> = None; let mut last_new: Option> = None; @@ -553,6 +635,7 @@ pub(crate) fn execute_partiql_in_state( keys: last_key, old_image: last_old, new_image: last_new, + projection: None, }) } else if upper.starts_with("DELETE") { let from_pos = find_outside_quotes(&upper, "FROM").ok_or_else(|| { @@ -574,6 +657,10 @@ pub(crate) fn execute_partiql_in_state( } let where_clause = rest.trim()[5..].trim(); let table = get_table_mut(&mut state.tables, &table_name)?; + require_partiql_key_equality( + table, + partiql_where_conditions(where_clause, parameters).as_ref(), + )?; let mut indices = find_partiql_where_indices(table, where_clause, parameters)?; // Ids are stable across removals, so any order is safe; newest first // keeps the reported row the first match in storage order. @@ -592,6 +679,7 @@ pub(crate) fn execute_partiql_in_state( keys: last_key, old_image: last_old, new_image: None, + projection: None, }) } else { Err(AwsServiceError::aws_error( @@ -602,7 +690,152 @@ pub(crate) fn execute_partiql_in_state( } } -fn split_partiql_returning_clause(where_clause: &str) -> (&str, bool) { +fn malformed_statement() -> AwsServiceError { + AwsServiceError::aws_error( + StatusCode::BAD_REQUEST, + "ValidationException", + "Statement wasn't well formed, can't be processed: Invalid column list", + ) +} + +/// The document paths of a SELECT column list, each a list of segments: an +/// attribute name (bare or double-quoted) or a list index. `None` for `*`. +/// A list the parser cannot read exactly is a ValidationException -- never a +/// shorter path, which would return more than was asked for. +pub(crate) fn partiql_column_paths( + columns: &str, +) -> Result>>, AwsServiceError> { + let columns = columns.trim(); + if columns == "*" { + return Ok(None); + } + if columns.is_empty() { + return Err(malformed_statement()); + } + let mut paths = Vec::new(); + for column in split_on_top_level_keyword(columns, ",") { + let chars: Vec = column.trim().chars().collect(); + let mut i = 0; + let mut path = Vec::new(); + let skip_ws = |i: &mut usize| { + while *i < chars.len() && chars[*i].is_whitespace() { + *i += 1; + } + }; + loop { + skip_ws(&mut i); + // A name. + let name: String = if chars.get(i) == Some(&'"') { + let start = i + 1; + let end = (start..chars.len()) + .find(|&j| chars[j] == '"') + .ok_or_else(malformed_statement)?; + i = end + 1; + chars[start..end].iter().collect() + } else { + let start = i; + while i < chars.len() + && !matches!(chars[i], '.' | '[' | ']' | '"') + && !chars[i].is_whitespace() + { + i += 1; + } + chars[start..i].iter().collect() + }; + if name.is_empty() { + return Err(malformed_statement()); + } + path.push(PathSegment::Name(name)); + // Any list indexes. + loop { + skip_ws(&mut i); + if chars.get(i) != Some(&'[') { + break; + } + i += 1; + skip_ws(&mut i); + let start = i; + while i < chars.len() && chars[i].is_ascii_digit() { + i += 1; + } + let index: usize = chars[start..i] + .iter() + .collect::() + .parse() + .map_err(|_| malformed_statement())?; + skip_ws(&mut i); + if chars.get(i) != Some(&']') { + return Err(malformed_statement()); + } + i += 1; + path.push(PathSegment::Index(index)); + } + skip_ws(&mut i); + match chars.get(i) { + None => break, + Some('.') => i += 1, + Some(_) => return Err(malformed_statement()), + } + } + paths.push(path); + } + Ok(Some(paths)) +} + +/// One step of a document path. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum PathSegment { + Name(String), + Index(usize), +} + +/// A SELECT column list as a ProjectionExpression request fragment, or `None` +/// for `*`. Every name goes through an expression attribute name, so a quoted +/// name containing a dot stays one attribute. +fn partiql_projection(columns: &str) -> Result, AwsServiceError> { + let Some(paths) = partiql_column_paths(columns)? else { + return Ok(None); + }; + let mut names = serde_json::Map::new(); + let mut rendered = Vec::new(); + for path in paths { + let mut out = String::new(); + for segment in path { + match segment { + PathSegment::Name(name) => { + if !out.is_empty() { + out.push('.'); + } + let placeholder = format!("#c{}", names.len()); + names.insert(placeholder.clone(), json!(name)); + out.push_str(&placeholder); + } + PathSegment::Index(index) => out.push_str(&format!("[{index}]")), + } + } + rendered.push(out); + } + Ok(Some(json!({ + "ProjectionExpression": rendered.join(", "), + "ExpressionAttributeNames": names, + }))) +} + +/// Apply a SELECT's column list to the `Items` of a response. +pub(crate) fn project_partiql_response(response: &mut Value, projection: Option<&Value>) { + let Some(body) = projection else { + return; + }; + if let Some(items) = response.get_mut("Items").and_then(Value::as_array_mut) { + for item in items.iter_mut() { + let row: HashMap = + serde_json::from_value(item.take()).unwrap_or_default(); + *item = json!(crate::service::helpers::project_item(&row, body)); + } + } +} + +pub(crate) fn split_partiql_returning_clause(where_clause: &str) -> (&str, bool) { // `to_ascii_uppercase` preserves byte length and never touches non-ASCII // bytes, so char boundaries stay aligned between `upper` and `where_clause`. // Iterate real char boundaries (not raw byte indices) so a non-ASCII byte @@ -625,7 +858,7 @@ fn split_partiql_returning_clause(where_clause: &str) -> (&str, bool) { (where_clause, false) } -fn prepare_partiql_update_expression( +pub(crate) fn prepare_partiql_update_expression( set_clause: &str, parameters: &[Value], ) -> (String, HashMap) { @@ -970,6 +1203,126 @@ fn match_where_keyword_at_start(upper: &[u8], i: usize) -> Option<(WhereTok<'sta /// Parse a WHERE clause into [`PartiqlExpr`]. Returns `None` when the /// clause has no logical operators OR fails to parse — callers fall /// back to the legacy AND-only evaluator in that case. +/// A WHERE clause's conditions the way the executor reads them: the parsed +/// expression tree, or -- for a clause that only the legacy AND-list parser +/// understands -- that list folded into one AND. `None` when neither parser +/// accepts the clause (the executor then rejects the statement). +pub(crate) fn partiql_where_conditions( + where_clause: &str, + parameters: &[Value], +) -> Option { + if let Some(expr) = parse_partiql_where_expr(where_clause, parameters) { + return Some(expr); + } + let conditions = split_partiql_and_clauses(where_clause); + let parsed = parse_partiql_conditions(&conditions, parameters); + if parsed.is_empty() || parsed.len() != conditions.len() { + return None; + } + parsed + .into_iter() + .map(PartiqlExpr::Cond) + .reduce(|l, r| PartiqlExpr::And(Box::new(l), Box::new(r))) +} + +fn partiql_cond_attribute(cond: &PartiqlCond) -> &str { + use PartiqlCond::*; + match cond { + Eq(a, _) + | Ne(a, _) + | Lt(a, _) + | Le(a, _) + | Gt(a, _) + | Ge(a, _) + | Between(a, _, _) + | In(a, _) + | Like(a, _) + | BeginsWith(a, _) + | Contains(a, _) + | AttributeExists(a) + // The executor reads the condition's attribute as one top-level name + // (`a.b` is an attribute literally named `a.b`), so that is the name. + | AttributeNotExists(a) => a.trim().trim_matches('"'), + } +} + +/// Every top-level attribute a WHERE expression reads. +pub(crate) fn partiql_expr_attributes(expr: &PartiqlExpr, out: &mut Vec) { + match expr { + PartiqlExpr::Cond(c) => out.push(partiql_cond_attribute(c).to_string()), + PartiqlExpr::And(l, r) | PartiqlExpr::Or(l, r) => { + partiql_expr_attributes(l, out); + partiql_expr_attributes(r, out); + } + PartiqlExpr::Not(e) => partiql_expr_attributes(e, out), + } +} + +/// The values a WHERE expression confines `attr` to, if it confines it at +/// all: every row it selects has `attr` equal to one of them. `None` means +/// rows with any value of `attr` can match. +pub(crate) fn partiql_pinned_values(expr: &PartiqlExpr, attr: &str) -> Option> { + match expr { + PartiqlExpr::Cond(PartiqlCond::Eq(a, v)) if a.trim().trim_matches('"') == attr => { + Some(vec![v.clone()]) + } + PartiqlExpr::Cond(PartiqlCond::In(a, vs)) if a.trim().trim_matches('"') == attr => { + Some(vs.clone()) + } + PartiqlExpr::Cond(_) | PartiqlExpr::Not(_) => None, + PartiqlExpr::And(l, r) => match ( + partiql_pinned_values(l, attr), + partiql_pinned_values(r, attr), + ) { + (Some(a), Some(b)) => Some( + a.into_iter() + .filter(|v| b.iter().any(|w| values_equal(Some(v), Some(w)))) + .collect(), + ), + (Some(a), None) | (None, Some(a)) => Some(a), + (None, None) => None, + }, + PartiqlExpr::Or(l, r) => { + let mut a = partiql_pinned_values(l, attr)?; + a.extend(partiql_pinned_values(r, attr)?); + Some(a) + } + } +} + +/// The single value an AND-joined WHERE expression equates `attr` to. +fn partiql_equality_on(expr: &PartiqlExpr, attr: &str) -> bool { + match expr { + PartiqlExpr::Cond(PartiqlCond::Eq(a, _)) => a.trim().trim_matches('"') == attr, + PartiqlExpr::And(l, r) => partiql_equality_on(l, attr) || partiql_equality_on(r, attr), + _ => false, + } +} + +/// PartiQL UPDATE and DELETE act on exactly one item, so their WHERE clause +/// must equate every primary-key attribute to a value; DynamoDB rejects any +/// other clause. Without this a clause on a non-key attribute rewrote or +/// removed every matching row across partitions. +pub(crate) fn require_partiql_key_equality( + table: &DynamoTable, + conditions: Option<&PartiqlExpr>, +) -> Result<(), AwsServiceError> { + let keyed = conditions.is_some_and(|expr| { + std::iter::once(table.hash_key_name()) + .chain(table.range_key_name()) + .all(|key| partiql_equality_on(expr, key)) + }); + if keyed { + Ok(()) + } else { + Err(AwsServiceError::aws_error( + StatusCode::BAD_REQUEST, + "ValidationException", + "Where clause does not contain a mandatory equality on all key attributes", + )) + } +} + pub(crate) fn parse_partiql_where_expr( where_clause: &str, parameters: &[Value], @@ -1273,9 +1626,12 @@ fn parse_one_partiql_condition( if let Some(i) = find_outside_quotes(&upper, " IN ") { let attr = cond[..i].trim().trim_matches('"').to_string(); let after = cond[i + 4..].trim(); + // DynamoDB's PartiQL writes the list in brackets (`IN ['a', 'b']`); + // parentheses are accepted too. let inner = after - .strip_prefix('(') - .and_then(|s| s.strip_suffix(')'))? + .strip_prefix('[') + .and_then(|s| s.strip_suffix(']')) + .or_else(|| after.strip_prefix('(').and_then(|s| s.strip_suffix(')')))? .trim(); let mut vals = Vec::new(); for raw in inner.split(',') { @@ -1662,6 +2018,11 @@ mod quote_aware_tests { // The literal WHERE is skipped; the real (last) one is found. let s = "note = 'go WHERE you' WHERE id = 1"; assert_eq!(find_outside_quotes(s, "WHERE"), s.rfind("WHERE")); + // Keywords match whole words only, and never inside a quoted name. + let s = "SET SOMEWHERE = 1 WHERE PK = 'A'"; + assert_eq!(find_outside_quotes(s, "WHERE"), Some(18)); + let s = "SELECT \"FROM\" FROM T"; + assert_eq!(find_outside_quotes(s, "FROM"), Some(14)); } #[test] diff --git a/crates/fakecloud-dynamodb/src/service/iam.rs b/crates/fakecloud-dynamodb/src/service/iam.rs new file mode 100644 index 000000000..9881aa225 --- /dev/null +++ b/crates/fakecloud-dynamodb/src/service/iam.rs @@ -0,0 +1,862 @@ +//! IAM authorization for DynamoDB and DynamoDB Streams requests: which +//! `dynamodb:*` actions each operation needs, on which resources. +//! +//! Follows the AWS Service Authorization Reference for DynamoDB. Most +//! operations need their namesake action on one table. The exceptions: +//! +//! - A batch needs `BatchGetItem` / `BatchWriteItem` on every table it names. +//! - A transaction needs the per-item action (`GetItem`, `PutItem`, +//! `UpdateItem`, `DeleteItem`, `ConditionCheckItem`) on each item's table; +//! there is no `dynamodb:TransactWriteItems` action. +//! - PartiQL statements need `PartiQLSelect` / `PartiQLInsert` / +//! `PartiQLUpdate` / `PartiQLDelete` on the table (or, for a SELECT, the +//! index) each statement names. +//! - `Query`, `Scan` and the contributor-insights operations target the +//! index when `IndexName` is given. +//! - `CreateTable` also needs `TagResource` when it carries `Tags` and +//! `PutResourcePolicy` when it carries `ResourcePolicy`. +//! - A restore needs its own action on the source plus the data-plane +//! actions DynamoDB uses to write the target table. +//! +//! A `TableName` given as an ARN authorizes against that ARN, so the +//! resource carries the table's own account and region. + +use std::collections::HashMap; + +use fakecloud_core::auth::IamAction; +use fakecloud_core::service::AwsRequest; +use serde_json::Value; + +use super::helpers::partiql::{find_outside_quotes, parse_partiql_table_name}; + +const SERVICE: &str = "dynamodb"; + +/// The data-plane actions DynamoDB performs on a restore's target table. +const RESTORE_TARGET_ACTIONS: [&str; 7] = [ + "BatchWriteItem", + "DeleteItem", + "GetItem", + "PutItem", + "Query", + "Scan", + "UpdateItem", +]; + +/// Resolves the tables a request names to the ARNs to authorize. +struct Scope<'a> { + account: &'a str, + region: &'a str, + accounts: &'a fakecloud_core::multi_account::MultiAccountState, +} + +impl Scope<'_> { + /// The ARN to authorize for a `TableName` value (a name, or a table ARN). + /// + /// When the table exists this is its own stored ARN: the handler serves + /// that table, looked up by name in the account, so authorizing an ARN + /// built from the request's region -- or taken from a caller-written ARN + /// -- would check a resource the request does not actually touch, and a + /// policy scoped to the real table's region could be sidestepped. A table + /// that does not exist yet (CreateTable) is authorized at the ARN it will + /// get: the caller's account and the request's region. + fn table(&self, name_or_arn: &str) -> String { + // Handlers look a table up by name in the caller's own account, whatever + // account a table ARN names, so that is the table to authorize. + let name = match table_arn_of(name_or_arn) { + Some(arn) => arn + .rsplit("table/") + .next() + .unwrap_or(name_or_arn) + .to_string(), + None => name_or_arn.to_string(), + }; + if let Some(table) = self + .accounts + .get(self.account) + .and_then(|state| state.tables.get(&name)) + { + return table.arn.clone(); + } + match table_arn_of(name_or_arn) { + Some(arn) => arn, + None => format!( + "arn:aws:dynamodb:{}:{}:table/{name_or_arn}", + self.region, self.account + ), + } + } + + fn index(&self, table: &str, index: &str) -> String { + format!("{}/index/{index}", self.table(table)) + } + + fn global_table(&self, name: &str) -> String { + format!("arn:aws:dynamodb::{}:global-table/{name}", self.account) + } +} + +/// `arn:aws:dynamodb:REGION:ACCOUNT:table/NAME` for an ARN naming a table or +/// one of its sub-resources, or `None` for anything else. +fn table_arn_of(arn: &str) -> Option { + let rest = arn.strip_prefix("arn:aws:dynamodb:")?; + let (scope, resource) = rest.split_once(":table/")?; + let name = resource.split('/').next().filter(|n| !n.is_empty())?; + Some(format!("arn:aws:dynamodb:{scope}:table/{name}")) +} + +fn action(name: &'static str, resource: String) -> IamAction { + IamAction { + service: SERVICE, + action: name, + resource, + } +} + +/// A body string field, or `None` when absent or empty. +fn field<'a>(body: &'a Value, name: &str) -> Option<&'a str> { + body[name].as_str().filter(|s| !s.is_empty()) +} + +/// The `dynamodb:*` authorizations a DynamoDB request needs. Empty only for +/// an operation this service does not implement. +pub(crate) fn actions_for( + state: &crate::state::SharedDynamoDbState, + request: &AwsRequest, +) -> Vec { + let body: Value = serde_json::from_slice(&request.body).unwrap_or(Value::Null); + let account = request + .principal + .as_ref() + .map(|p| p.account_id.as_str()) + .unwrap_or(request.account_id.as_str()); + let accounts = state.read(); + let scope = Scope { + account, + region: request.region.as_str(), + accounts: &accounts, + }; + let op: &'static str = match DYNAMODB_ACTIONS + .iter() + .find(|a| **a == request.action.as_str()) + { + Some(op) => op, + None => return Vec::new(), + }; + // A required name that is missing still maps, to `*`: the request is + // malformed, and the handler rejects it with its own validation error + // once the caller is authorized for the operation at all. + let table = |key: &str| field(&body, key).map_or_else(|| "*".to_string(), |t| scope.table(t)); + let table_or_index = || match (field(&body, "TableName"), field(&body, "IndexName")) { + (Some(t), Some(i)) => scope.index(t, i), + (Some(t), None) => scope.table(t), + _ => "*".to_string(), + }; + let arn_field = |key: &str| field(&body, key).unwrap_or("*").to_string(); + + match op { + "GetItem" + | "PutItem" + | "UpdateItem" + | "DeleteItem" + | "CreateBackup" + | "DeleteTable" + | "DescribeTable" + | "UpdateTable" + | "DescribeTimeToLive" + | "UpdateTimeToLive" + | "DescribeContinuousBackups" + | "UpdateContinuousBackups" + | "DescribeKinesisStreamingDestination" + | "EnableKinesisStreamingDestination" + | "DisableKinesisStreamingDestination" + | "UpdateKinesisStreamingDestination" + | "DescribeTableReplicaAutoScaling" + | "UpdateTableReplicaAutoScaling" => { + vec![action(op, table("TableName"))] + } + "Query" + | "Scan" + | "DescribeContributorInsights" + | "UpdateContributorInsights" + | "SearchVectors" => vec![action(op, table_or_index())], + "ListTables" + | "DescribeLimits" + | "DescribeEndpoints" + | "ListBackups" + | "ListContributorInsights" + | "ListGlobalTables" => vec![action(op, "*".to_string())], + "ListExports" | "ListImports" => vec![action(op, table("TableArn"))], + "ExportTableToPointInTime" => vec![action(op, table("TableArn"))], + "DescribeBackup" | "DeleteBackup" => vec![action(op, arn_field("BackupArn"))], + "DescribeExport" => vec![action(op, arn_field("ExportArn"))], + "DescribeImport" => vec![action(op, arn_field("ImportArn"))], + "TagResource" + | "UntagResource" + | "ListTagsOfResource" + | "GetResourcePolicy" + | "PutResourcePolicy" + | "DeleteResourcePolicy" => { + vec![action(op, arn_field("ResourceArn"))] + } + "CreateTable" => { + let resource = table("TableName"); + let mut out = vec![action("CreateTable", resource.clone())]; + if body["Tags"].as_array().is_some_and(|t| !t.is_empty()) { + out.push(action("TagResource", resource.clone())); + } + if field(&body, "ResourcePolicy").is_some() { + out.push(action("PutResourcePolicy", resource)); + } + out + } + "ImportTable" => { + let name = body["TableCreationParameters"]["TableName"] + .as_str() + .filter(|s| !s.is_empty()); + vec![action( + "ImportTable", + name.map_or_else(|| "*".to_string(), |n| scope.table(n)), + )] + } + "RestoreTableFromBackup" => { + let target = table("TargetTableName"); + let mut out = vec![ + action("RestoreTableFromBackup", arn_field("BackupArn")), + action("RestoreTableFromBackup", target.clone()), + ]; + out.extend( + RESTORE_TARGET_ACTIONS + .iter() + .map(|a| action(a, target.clone())), + ); + out + } + "RestoreTableToPointInTime" => { + // The handler takes `SourceTableName` over `SourceTableArn`, so the + // table authorized has to be chosen the same way. + let source = field(&body, "SourceTableName") + .or_else(|| field(&body, "SourceTableArn")) + .map_or_else(|| "*".to_string(), |t| scope.table(t)); + let target = table("TargetTableName"); + let mut out = vec![action("RestoreTableToPointInTime", source)]; + out.extend( + RESTORE_TARGET_ACTIONS + .iter() + .map(|a| action(a, target.clone())), + ); + out + } + "CreateGlobalTable" | "UpdateGlobalTable" | "UpdateGlobalTableSettings" => { + let name = field(&body, "GlobalTableName").unwrap_or("*"); + vec![ + action(op, scope.global_table(name)), + action(op, scope.table(name)), + ] + } + "DescribeGlobalTable" | "DescribeGlobalTableSettings" => { + let name = field(&body, "GlobalTableName").unwrap_or("*"); + vec![action(op, scope.global_table(name))] + } + "BatchGetItem" | "BatchWriteItem" => { + let tables = batch_table_names(&body["RequestItems"]); + if tables.is_empty() { + return vec![action(op, "*".to_string())]; + } + tables + .into_iter() + .map(|t| action(op, scope.table(t))) + .collect() + } + "TransactGetItems" | "TransactWriteItems" => { + let mut out = Vec::new(); + for item in body["TransactItems"].as_array().into_iter().flatten() { + for (member, item_action) in [ + ("Get", "GetItem"), + ("Put", "PutItem"), + ("Update", "UpdateItem"), + ("Delete", "DeleteItem"), + ("ConditionCheck", "ConditionCheckItem"), + ] { + if let Some(name) = item[member]["TableName"].as_str() { + push_unique(&mut out, action(item_action, scope.table(name))); + } + } + } + if out.is_empty() { + let fallback = if op == "TransactGetItems" { + "GetItem" + } else { + "PutItem" + }; + out.push(action(fallback, "*".to_string())); + } + out + } + "ExecuteStatement" => { + let statement = field(&body, "Statement").unwrap_or(""); + vec![partiql_action(&scope, statement)] + } + "BatchExecuteStatement" | "ExecuteTransaction" => { + let list = if op == "BatchExecuteStatement" { + &body["Statements"] + } else { + &body["TransactStatements"] + }; + let mut out = Vec::new(); + for statement in list.as_array().into_iter().flatten() { + let text = statement["Statement"].as_str().unwrap_or(""); + push_unique(&mut out, partiql_action(&scope, text)); + } + if out.is_empty() { + out.push(action("PartiQLSelect", "*".to_string())); + } + out + } + _ => Vec::new(), + } +} + +/// The DynamoDB Streams operations' authorizations. +pub(crate) fn streams_actions_for(request: &AwsRequest) -> Vec { + let body: Value = serde_json::from_slice(&request.body).unwrap_or(Value::Null); + match request.action.as_str() { + "ListStreams" => vec![action("ListStreams", "*".to_string())], + "DescribeStream" => vec![action( + "DescribeStream", + field(&body, "StreamArn").unwrap_or("*").to_string(), + )], + "GetShardIterator" => vec![action( + "GetShardIterator", + field(&body, "StreamArn").unwrap_or("*").to_string(), + )], + // An iterator is `STREAM_ARN|SHARD|SEQUENCE`; the stream it reads is + // the resource. + "GetRecords" => { + let stream = field(&body, "ShardIterator") + .and_then(|it| it.split('|').next()) + .filter(|arn| arn.starts_with("arn:")) + .unwrap_or("*"); + vec![action("GetRecords", stream.to_string())] + } + _ => Vec::new(), + } +} + +/// Every DynamoDB control- and data-plane operation this service serves. +const DYNAMODB_ACTIONS: &[&str] = &[ + "BatchExecuteStatement", + "BatchGetItem", + "BatchWriteItem", + "CreateBackup", + "CreateGlobalTable", + "CreateTable", + "DeleteBackup", + "DeleteItem", + "DeleteResourcePolicy", + "DeleteTable", + "DescribeBackup", + "DescribeContinuousBackups", + "DescribeContributorInsights", + "DescribeEndpoints", + "DescribeExport", + "DescribeGlobalTable", + "DescribeGlobalTableSettings", + "DescribeImport", + "DescribeKinesisStreamingDestination", + "DescribeLimits", + "DescribeTable", + "DescribeTableReplicaAutoScaling", + "DescribeTimeToLive", + "DisableKinesisStreamingDestination", + "EnableKinesisStreamingDestination", + "ExecuteStatement", + "ExecuteTransaction", + "ExportTableToPointInTime", + "GetItem", + "GetResourcePolicy", + "ImportTable", + "ListBackups", + "ListContributorInsights", + "ListExports", + "ListGlobalTables", + "ListImports", + "ListTables", + "ListTagsOfResource", + "PutItem", + "PutResourcePolicy", + "Query", + "RestoreTableFromBackup", + "RestoreTableToPointInTime", + "Scan", + "SearchVectors", + "TagResource", + "TransactGetItems", + "TransactWriteItems", + "UntagResource", + "UpdateContinuousBackups", + "UpdateContributorInsights", + "UpdateGlobalTable", + "UpdateGlobalTableSettings", + "UpdateItem", + "UpdateKinesisStreamingDestination", + "UpdateTable", + "UpdateTableReplicaAutoScaling", + "UpdateTimeToLive", +]; + +fn batch_table_names(request_items: &Value) -> Vec<&str> { + request_items + .as_object() + .map(|m| m.keys().map(String::as_str).collect()) + .unwrap_or_default() +} + +fn push_unique(out: &mut Vec, a: IamAction) { + if !out.contains(&a) { + out.push(a); + } +} + +/// The PartiQL action a statement's verb needs, and the table it names -- +/// with `.index` appended for a SELECT from `"table"."index"`. `None` for a +/// statement too malformed to name a table. +pub(crate) fn partiql_verb_and_table(statement: &str) -> Option<(&'static str, String)> { + let trimmed = statement.trim(); + let upper = trimmed.to_ascii_uppercase(); + let (verb, keyword) = if upper.starts_with("SELECT") { + ("PartiQLSelect", Some("FROM")) + } else if upper.starts_with("INSERT") { + ("PartiQLInsert", Some("INTO")) + } else if upper.starts_with("UPDATE") { + ("PartiQLUpdate", None) + } else if upper.starts_with("DELETE") { + ("PartiQLDelete", Some("FROM")) + } else { + return None; + }; + let after = match keyword { + Some(kw) => &trimmed[find_outside_quotes(&upper, kw)? + kw.len()..], + None => &trimmed["UPDATE".len()..], + }; + let (table, _) = parse_partiql_table_name(after); + (!table.is_empty()).then_some((verb, table)) +} + +/// The PartiQL action a statement needs, on the table (or, for a SELECT +/// from `"table"."index"`, the index) it names. A statement too malformed to +/// name a table maps to `PartiQLSelect` on `*`, leaving the syntax error to +/// the handler. +fn partiql_action(scope: &Scope<'_>, statement: &str) -> IamAction { + let Some((verb, table)) = partiql_verb_and_table(statement) else { + return action("PartiQLSelect", "*".to_string()); + }; + if verb == "PartiQLSelect" { + if let Some(index) = partiql_select_index(statement) { + return action(verb, scope.index(&table, &index)); + } + } + action(verb, scope.table(&table)) +} + +/// The index a `SELECT ... FROM "table"."index"` reads, if any. +pub(crate) fn partiql_select_index(statement: &str) -> Option { + let trimmed = statement.trim(); + let upper = trimmed.to_ascii_uppercase(); + let from = find_outside_quotes(&upper, "FROM")?; + let (_, rest) = parse_partiql_table_name(&trimmed[from + "FROM".len()..]); + let (index, _) = parse_partiql_table_name(rest.strip_prefix('.')?); + (!index.is_empty()).then_some(index) +} + +/// Tags on the table a resource ARN names (a table, or its index or +/// stream), for `aws:ResourceTag/*`. `Some(empty)` for `*`; `None` when the +/// ARN names no table this state holds. +pub(crate) fn resource_tags( + state: &crate::state::SharedDynamoDbState, + resource_arn: &str, +) -> Option> { + if resource_arn == "*" { + return Some(HashMap::new()); + } + let table_arn = table_arn_of(resource_arn)?; + let account = table_arn.split(':').nth(4)?; + let name = table_arn.rsplit("table/").next()?; + let accounts = state.read(); + let table = accounts.get(account)?.tables.get(name)?; + Some( + table + .tags + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(), + ) +} + +/// Tags a request writes, for `aws:RequestTag/*` and `aws:TagKeys`: +/// `CreateTable` / `TagResource` carry `Tags: [{Key, Value}]`, and +/// `UntagResource` names keys only. +pub(crate) fn request_tags(request: &AwsRequest, action: &str) -> Option> { + let body: Value = serde_json::from_slice(&request.body).unwrap_or(Value::Null); + match action { + "CreateTable" | "TagResource" => Some( + body["Tags"] + .as_array() + .into_iter() + .flatten() + .filter_map(|t| { + Some(( + t["Key"].as_str()?.to_string(), + t["Value"].as_str().unwrap_or_default().to_string(), + )) + }) + .collect(), + ), + "UntagResource" => Some( + body["TagKeys"] + .as_array() + .into_iter() + .flatten() + .filter_map(|k| Some((k.as_str()?.to_string(), String::new()))) + .collect(), + ), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use fakecloud_core::service::AwsService; + use serde_json::json; + + const ACCOUNT: &str = "111122223333"; + const TABLE: &str = "arn:aws:dynamodb:eu-west-1:111122223333:table/Orders"; + + fn req(action: &str, body: Value) -> AwsRequest { + AwsRequest { + service: "dynamodb".to_string(), + action: action.to_string(), + region: "eu-west-1".to_string(), + account_id: ACCOUNT.to_string(), + request_id: "test-id".to_string(), + headers: http::HeaderMap::new(), + query_params: HashMap::new(), + body: serde_json::to_vec(&body).unwrap().into(), + body_stream: parking_lot::Mutex::new(None), + path_segments: vec![], + raw_path: "/".to_string(), + raw_query: String::new(), + method: http::Method::POST, + is_query_protocol: false, + access_key_id: None, + principal: None, + } + } + + fn test_state() -> crate::state::SharedDynamoDbState { + std::sync::Arc::new(parking_lot::RwLock::new( + fakecloud_core::multi_account::MultiAccountState::new(ACCOUNT, "eu-west-1", ""), + )) + } + + fn pairs(actions: Vec) -> Vec<(String, String)> { + actions + .into_iter() + .map(|a| (a.action_string(), a.resource)) + .collect() + } + + fn one(action: &str, resource: &str) -> Vec<(String, String)> { + vec![(format!("dynamodb:{action}"), resource.to_string())] + } + + /// Strict enforcement denies an operation with no mapping, so every + /// operation either service serves must map to at least one action. + #[test] + fn every_served_operation_maps_to_an_action() { + let state: crate::state::SharedDynamoDbState = + std::sync::Arc::new(parking_lot::RwLock::new( + fakecloud_core::multi_account::MultiAccountState::new(ACCOUNT, "eu-west-1", ""), + )); + let service = crate::DynamoDbService::new(state.clone()); + for op in service.supported_actions() { + assert!( + !actions_for(&test_state(), &req(op, json!({}))).is_empty(), + "DynamoDB {op} has no IAM mapping" + ); + } + let streams = crate::DynamoDbStreamsService::new(state); + for op in streams.supported_actions() { + assert!( + !streams_actions_for(&req(op, json!({}))).is_empty(), + "DynamoDB Streams {op} has no IAM mapping" + ); + } + } + + #[test] + fn item_and_table_operations_target_the_table() { + for op in [ + "GetItem", + "PutItem", + "UpdateItem", + "DeleteItem", + "DescribeTable", + ] { + assert_eq!( + pairs(actions_for( + &test_state(), + &req(op, json!({"TableName": "Orders"})) + )), + one(op, TABLE) + ); + } + // A table ARN is authorized as given, in its own account and region. + let foreign = "arn:aws:dynamodb:us-east-2:444455556666:table/Shared"; + assert_eq!( + pairs(actions_for( + &test_state(), + &req("GetItem", json!({"TableName": foreign})) + )), + one("GetItem", foreign) + ); + assert_eq!( + pairs(actions_for(&test_state(), &req("ListTables", json!({})))), + one("ListTables", "*") + ); + } + + #[test] + fn query_and_scan_target_the_index_when_named() { + let body = json!({"TableName": "Orders", "IndexName": "by-customer"}); + let index = format!("{TABLE}/index/by-customer"); + assert_eq!( + pairs(actions_for(&test_state(), &req("Query", body.clone()))), + one("Query", &index) + ); + assert_eq!( + pairs(actions_for(&test_state(), &req("Scan", body))), + one("Scan", &index) + ); + assert_eq!( + pairs(actions_for( + &test_state(), + &req("Scan", json!({"TableName": "Orders"})) + )), + one("Scan", TABLE) + ); + } + + /// Batches need the batch action on every table; transactions need the + /// per-item action on each item's table, once per distinct pair. + #[test] + fn batches_and_transactions_authorize_every_table() { + let batch = json!({"RequestItems": {"Orders": [], "Customers": []}}); + let mut got = pairs(actions_for(&test_state(), &req("BatchWriteItem", batch))); + got.sort(); + assert_eq!( + got, + vec![ + ( + "dynamodb:BatchWriteItem".to_string(), + "arn:aws:dynamodb:eu-west-1:111122223333:table/Customers".to_string() + ), + ("dynamodb:BatchWriteItem".to_string(), TABLE.to_string()), + ] + ); + + let transact = json!({"TransactItems": [ + {"Put": {"TableName": "Orders"}}, + {"Put": {"TableName": "Orders"}}, + {"ConditionCheck": {"TableName": "Customers"}}, + {"Delete": {"TableName": "Orders"}}, + {"Update": {"TableName": "Orders"}} + ]}); + assert_eq!( + pairs(actions_for( + &test_state(), + &req("TransactWriteItems", transact) + )), + vec![ + ("dynamodb:PutItem".to_string(), TABLE.to_string()), + ( + "dynamodb:ConditionCheckItem".to_string(), + "arn:aws:dynamodb:eu-west-1:111122223333:table/Customers".to_string() + ), + ("dynamodb:DeleteItem".to_string(), TABLE.to_string()), + ("dynamodb:UpdateItem".to_string(), TABLE.to_string()), + ] + ); + assert_eq!( + pairs(actions_for( + &test_state(), + &req( + "TransactGetItems", + json!({"TransactItems": [{"Get": {"TableName": "Orders"}}]}) + ) + )), + one("GetItem", TABLE) + ); + } + + #[test] + fn partiql_statements_map_to_partiql_actions() { + let cases = [ + ( + "SELECT * FROM \"Orders\" WHERE pk = 'a'", + "PartiQLSelect", + TABLE.to_string(), + ), + ("select * from Orders", "PartiQLSelect", TABLE.to_string()), + ( + "SELECT * FROM \"Orders\".\"by-customer\"", + "PartiQLSelect", + format!("{TABLE}/index/by-customer"), + ), + ( + "INSERT INTO \"Orders\" VALUE {'pk': 'a'}", + "PartiQLInsert", + TABLE.to_string(), + ), + ( + "UPDATE \"Orders\" SET x = 1 WHERE pk = 'a'", + "PartiQLUpdate", + TABLE.to_string(), + ), + ( + "DELETE FROM \"Orders\" WHERE pk = 'a'", + "PartiQLDelete", + TABLE.to_string(), + ), + ("EXPLAIN nonsense", "PartiQLSelect", "*".to_string()), + ]; + for (statement, action_name, resource) in cases { + assert_eq!( + pairs(actions_for( + &test_state(), + &req("ExecuteStatement", json!({"Statement": statement})) + )), + one(action_name, &resource), + "{statement}" + ); + } + let batch = json!({"Statements": [ + {"Statement": "INSERT INTO \"Orders\" VALUE {'pk': 'a'}"}, + {"Statement": "INSERT INTO \"Orders\" VALUE {'pk': 'b'}"}, + {"Statement": "DELETE FROM \"Orders\" WHERE pk = 'c'"} + ]}); + assert_eq!( + pairs(actions_for( + &test_state(), + &req("BatchExecuteStatement", batch) + )), + vec![ + ("dynamodb:PartiQLInsert".to_string(), TABLE.to_string()), + ("dynamodb:PartiQLDelete".to_string(), TABLE.to_string()), + ] + ); + } + + #[test] + fn create_table_and_restores_need_their_companion_actions() { + let create = json!({ + "TableName": "Orders", + "Tags": [{"Key": "team", "Value": "x"}], + "ResourcePolicy": "{}" + }); + assert_eq!( + pairs(actions_for(&test_state(), &req("CreateTable", create))), + vec![ + ("dynamodb:CreateTable".to_string(), TABLE.to_string()), + ("dynamodb:TagResource".to_string(), TABLE.to_string()), + ("dynamodb:PutResourcePolicy".to_string(), TABLE.to_string()), + ] + ); + assert_eq!( + pairs(actions_for( + &test_state(), + &req("CreateTable", json!({"TableName": "Orders"})) + )), + one("CreateTable", TABLE) + ); + + let backup = format!("{TABLE}/backup/01700000000000-abcd"); + let restored = pairs(actions_for( + &test_state(), + &req( + "RestoreTableFromBackup", + json!({"BackupArn": backup, "TargetTableName": "Copy"}), + ), + )); + let copy = "arn:aws:dynamodb:eu-west-1:111122223333:table/Copy"; + assert_eq!( + restored[0], + ( + "dynamodb:RestoreTableFromBackup".to_string(), + backup.clone() + ) + ); + assert!(restored.contains(&("dynamodb:PutItem".to_string(), copy.to_string()))); + assert!(restored.contains(&("dynamodb:BatchWriteItem".to_string(), copy.to_string()))); + + let pitr = pairs(actions_for( + &test_state(), + &req( + "RestoreTableToPointInTime", + json!({"SourceTableName": "Orders", "TargetTableName": "Copy"}), + ), + )); + assert_eq!( + pitr[0], + ( + "dynamodb:RestoreTableToPointInTime".to_string(), + TABLE.to_string() + ) + ); + assert!(pitr.contains(&("dynamodb:UpdateItem".to_string(), copy.to_string()))); + } + + #[test] + fn streams_operations_target_the_stream() { + let stream = format!("{TABLE}/stream/2026-01-01T00:00:00.000"); + assert_eq!( + pairs(streams_actions_for(&req( + "DescribeStream", + json!({"StreamArn": stream}) + ))), + one("DescribeStream", &stream) + ); + assert_eq!( + pairs(streams_actions_for(&req( + "GetRecords", + json!({"ShardIterator": format!("{stream}|shardId-1|0")}) + ))), + one("GetRecords", &stream) + ); + assert_eq!( + pairs(streams_actions_for(&req("ListStreams", json!({})))), + one("ListStreams", "*") + ); + } + + #[test] + fn request_tags_cover_create_tag_and_untag() { + let create = req( + "CreateTable", + json!({"Tags": [{"Key": "team", "Value": "payments"}]}), + ); + assert_eq!( + request_tags(&create, "CreateTable"), + Some(HashMap::from([( + "team".to_string(), + "payments".to_string() + )])) + ); + let untag = req("UntagResource", json!({"TagKeys": ["team"]})); + assert_eq!( + request_tags(&untag, "UntagResource").map(|t| t.into_keys().collect::>()), + Some(vec!["team".to_string()]) + ); + assert_eq!(request_tags(&req("GetItem", json!({})), "GetItem"), None); + } +} diff --git a/crates/fakecloud-dynamodb/src/service/iam_conditions.rs b/crates/fakecloud-dynamodb/src/service/iam_conditions.rs new file mode 100644 index 000000000..c6abb4bf3 --- /dev/null +++ b/crates/fakecloud-dynamodb/src/service/iam_conditions.rs @@ -0,0 +1,1517 @@ +//! DynamoDB-specific IAM condition keys, for fine-grained access control. +//! +//! - `dynamodb:LeadingKeys` (alias `dynamodb:FirstPartitionKeyValues`): the +//! partition-key values of the items a request addresses -- a single +//! item's key, the partition-key equality of a Query, every key or item a +//! batch or transaction sends to the table being authorized, a PartiQL +//! statement's partition-key equality or inserted item. Absent for a Scan, +//! which addresses no particular partition. +//! - `dynamodb:Attributes`: the top-level attribute names the request +//! specifies -- key and item attributes, projections, `AttributesToGet`, +//! and every attribute an update, condition, filter or key-condition +//! expression references. A request with no projection reads every +//! attribute yet names only its key attributes, which is why AWS pairs +//! this key with `dynamodb:Select`. +//! - `dynamodb:Select`: the `Select` parameter, or the value DynamoDB +//! applies without one: `SPECIFIC_ATTRIBUTES` with a projection, +//! `ALL_PROJECTED_ATTRIBUTES` for an index query, `ALL_ATTRIBUTES` +//! otherwise. Only on operations that return item attributes. +//! - `dynamodb:ReturnValues`: the `ReturnValues` parameter, `NONE` by default +//! on a single-item write. +//! - `dynamodb:ReturnConsumedCapacity`: the parameter, `NONE` by default. +//! - `dynamodb:EnclosingOperation`: the transaction an item action runs in +//! (`TransactWriteItems`, `TransactGetItems`, `ExecuteTransaction`). +//! - `dynamodb:FullTableScan`: whether a PartiQL SELECT lacks a +//! partition-key equality and so reads the whole table. + +use std::collections::{BTreeMap, BTreeSet}; + +use fakecloud_core::auth::IamAction; +use fakecloud_core::service::AwsRequest; +use serde_json::Value; + +use crate::state::{attribute_type_and_value, SharedDynamoDbState}; + +use super::helpers::partiql::find_outside_quotes; + +/// Words in DynamoDB and PartiQL expressions that are never attribute names. +/// Operator words of both DynamoDB expressions and PartiQL: never attribute +/// names. Anything else is reported -- an extra name only narrows what an +/// attribute allow-list admits, a missing one would widen it. +const EXPRESSION_WORDS: &[&str] = &["and", "or", "not", "between", "in"]; + +/// UpdateExpression clause keywords. DynamoDB reserves them, so an attribute +/// named `set` is always written `#name` in a native expression. +const UPDATE_CLAUSE_WORDS: &[&str] = &["set", "remove", "add", "delete"]; + +struct Keys { + /// `None` when the partition keys could not be determined: the key is + /// then omitted, so a set operator cannot treat it as an empty match. + /// `Some(empty)` when the request addresses no particular partition. + leading: Option>, + attributes: BTreeSet, + select: Option, + return_values: Option, + return_consumed_capacity: Option, + enclosing_operation: Option<&'static str>, + full_table_scan: Option, +} + +impl Default for Keys { + fn default() -> Self { + Self { + leading: Some(BTreeSet::new()), + attributes: BTreeSet::new(), + select: None, + return_values: None, + return_consumed_capacity: None, + enclosing_operation: None, + full_table_scan: None, + } + } +} + +impl Keys { + /// Record a statement's or member's `Select`. Across the statements of a + /// batch or the members of a transaction the request reads as much as + /// its most permissive one, so that is the value reported. + fn merge_select(&mut self, select: String) { + fn rank(select: &str) -> u8 { + match select { + "COUNT" => 0, + "SPECIFIC_ATTRIBUTES" => 1, + "ALL_PROJECTED_ATTRIBUTES" => 2, + _ => 3, + } + } + if self + .select + .as_deref() + .is_none_or(|current| rank(&select) > rank(current)) + { + self.select = Some(select); + } + } + + fn add_leading(&mut self, value: String) { + if let Some(set) = &mut self.leading { + set.insert(value); + } + } + + fn into_map(self) -> BTreeMap> { + let mut out = BTreeMap::new(); + // An empty list means "no values" to set operators (ForAllValues is + // vacuously true); an omitted key means "unknown". + if let Some(leading) = self.leading { + let leading: Vec = leading.into_iter().collect(); + out.insert( + "dynamodb:firstpartitionkeyvalues".to_string(), + leading.clone(), + ); + out.insert("dynamodb:leadingkeys".to_string(), leading); + } + out.insert( + "dynamodb:attributes".to_string(), + self.attributes.into_iter().collect(), + ); + for (key, value) in [ + ("dynamodb:select", self.select), + ("dynamodb:returnvalues", self.return_values), + ( + "dynamodb:returnconsumedcapacity", + self.return_consumed_capacity, + ), + ( + "dynamodb:enclosingoperation", + self.enclosing_operation.map(str::to_string), + ), + ( + "dynamodb:fulltablescan", + self.full_table_scan.map(|b| b.to_string()), + ), + ] { + if let Some(v) = value { + out.insert(key.to_string(), vec![v]); + } + } + out + } +} + +/// The table (and index, if any) a resource ARN names, with the partition +/// key attribute the ARN's key conditions are about. +struct Target { + table_ref_name: String, + partition_key: String, + index: Option, +} + +fn target( + accounts: &fakecloud_core::multi_account::MultiAccountState, + resource: &str, +) -> Option { + let rest = resource.strip_prefix("arn:aws:dynamodb:")?; + let (scope, path) = rest.split_once(":table/")?; + let account = scope.split(':').nth(1)?; + let mut segments = path.split('/'); + let name = segments.next()?; + let index = match (segments.next(), segments.next()) { + (Some("index"), Some(index)) => Some(index.to_string()), + _ => None, + }; + let table = accounts.get(account)?.tables.get(name)?; + let partition_key = match &index { + Some(index) => table + .gsi + .iter() + .map(|g| (&g.index_name, &g.key_schema)) + .chain(table.lsi.iter().map(|l| (&l.index_name, &l.key_schema))) + .find(|(n, _)| *n == index) + .and_then(|(_, ks)| ks.iter().find(|k| k.key_type == "HASH")) + .map(|k| k.attribute_name.clone()) + .unwrap_or_else(|| table.hash_key_name().to_string()), + None => table.hash_key_name().to_string(), + }; + Some(Target { + table_ref_name: name.to_string(), + partition_key, + index, + }) +} + +/// Whether a request's `TableName` value names `target`'s table. +fn names_table(target: &Target, table_name: Option<&str>) -> bool { + let Some(name) = table_name else { + return false; + }; + let resolved = super::resolve_table_name(name); + resolved == target.table_ref_name +} + +/// The string an IAM condition compares for a scalar attribute value: the +/// string, the number's digits, or the binary's base64. +fn scalar_string(v: &Value) -> Option { + match attribute_type_and_value(v)? { + // Numbers compare by value in DynamoDB (`1.0` is key `1`), so the key + // is reported in canonical form. + ("N", Value::String(n)) => { + Some(super::helpers::partiql::canonical_number(n).unwrap_or_else(|| n.clone())) + } + ("S" | "B", Value::String(s)) => Some(s.clone()), + ("BOOL", Value::Bool(b)) => Some(b.to_string()), + _ => None, + } +} + +/// The DynamoDB condition keys for one authorization of `request`. +pub(crate) fn condition_keys( + state: &SharedDynamoDbState, + request: &AwsRequest, + action: &IamAction, +) -> BTreeMap> { + let body: Value = serde_json::from_slice(&request.body).unwrap_or(Value::Null); + let accounts = state.read(); + let target = target(&accounts, &action.resource); + let mut keys = Keys::default(); + let rcc = || { + Some( + body["ReturnConsumedCapacity"] + .as_str() + .unwrap_or("NONE") + .to_string(), + ) + }; + let names = expression_names(&body); + + match request.action.as_str() { + "GetItem" | "PutItem" | "UpdateItem" | "DeleteItem" => { + let item = if request.action == "PutItem" { + &body["Item"] + } else { + &body["Key"] + }; + if let Some(t) = &target { + add_leading(&mut keys, item, &t.partition_key); + } + add_item_attributes(&mut keys, item); + add_request_attributes(&mut keys, &body, &names); + keys.return_consumed_capacity = rcc(); + if request.action == "GetItem" { + keys.merge_select(implicit_select(&body, false)); + } else { + keys.return_values = + Some(body["ReturnValues"].as_str().unwrap_or("NONE").to_string()); + } + } + "Query" => { + if let Some(t) = &target { + match query_partition_value(&body, &names, &t.partition_key) { + Some(v) => keys.add_leading(v), + None => keys.leading = None, + } + keys.merge_select(implicit_select(&body, t.index.is_some())); + } + add_request_attributes(&mut keys, &body, &names); + keys.return_consumed_capacity = rcc(); + } + "Scan" => { + add_request_attributes(&mut keys, &body, &names); + keys.merge_select(implicit_select(&body, body["IndexName"].is_string())); + keys.return_consumed_capacity = rcc(); + } + "BatchGetItem" | "BatchWriteItem" => { + if let (Some(t), Some(items)) = (&target, body["RequestItems"].as_object()) { + for (table_name, entry) in items { + if !names_table(t, Some(table_name)) { + continue; + } + if request.action == "BatchGetItem" { + let entry_names = expression_names(entry); + for key in entry["Keys"].as_array().into_iter().flatten() { + add_leading(&mut keys, key, &t.partition_key); + add_item_attributes(&mut keys, key); + } + add_request_attributes(&mut keys, entry, &entry_names); + keys.merge_select(implicit_select(entry, false)); + } else { + for write in entry.as_array().into_iter().flatten() { + let item = if write["PutRequest"].is_object() { + &write["PutRequest"]["Item"] + } else { + &write["DeleteRequest"]["Key"] + }; + add_leading(&mut keys, item, &t.partition_key); + add_item_attributes(&mut keys, item); + } + } + } + } + keys.return_consumed_capacity = rcc(); + } + "TransactGetItems" | "TransactWriteItems" => { + keys.enclosing_operation = Some(if request.action == "TransactGetItems" { + "TransactGetItems" + } else { + "TransactWriteItems" + }); + let member = match action.action { + "GetItem" => "Get", + "PutItem" => "Put", + "UpdateItem" => "Update", + "DeleteItem" => "Delete", + _ => "ConditionCheck", + }; + if let Some(t) = &target { + for item in body["TransactItems"].as_array().into_iter().flatten() { + let op = &item[member]; + if !names_table(t, op["TableName"].as_str()) { + continue; + } + let addressed = if member == "Put" { + &op["Item"] + } else { + &op["Key"] + }; + add_leading(&mut keys, addressed, &t.partition_key); + add_item_attributes(&mut keys, addressed); + add_request_attributes(&mut keys, op, &expression_names(op)); + if member == "Get" { + keys.merge_select(implicit_select(op, false)); + } + } + } + keys.return_consumed_capacity = rcc(); + } + "ExecuteStatement" | "BatchExecuteStatement" | "ExecuteTransaction" => { + if request.action == "ExecuteTransaction" { + keys.enclosing_operation = Some("ExecuteTransaction"); + } + let statements: Vec<(&str, &[Value])> = match request.action.as_str() { + "ExecuteStatement" => vec![( + body["Statement"].as_str().unwrap_or(""), + body["Parameters"].as_array().map_or(&[][..], Vec::as_slice), + )], + _ => { + let list = if request.action == "BatchExecuteStatement" { + &body["Statements"] + } else { + &body["TransactStatements"] + }; + list.as_array() + .into_iter() + .flatten() + .map(|s| { + ( + s["Statement"].as_str().unwrap_or(""), + s["Parameters"].as_array().map_or(&[][..], Vec::as_slice), + ) + }) + .collect() + } + }; + if let Some(t) = &target { + for (statement, parameters) in statements { + let mapped = super::iam::partiql_verb_and_table(statement); + match mapped { + Some((verb, table_name)) if verb == action.action => { + if super::resolve_table_name(&table_name) != t.table_ref_name { + continue; + } + add_partiql_keys(&mut keys, t, statement, parameters, verb); + } + _ => {} + } + } + } + if request.action == "ExecuteStatement" { + keys.return_consumed_capacity = rcc(); + } + } + _ => {} + } + keys.into_map() +} + +fn add_leading(keys: &mut Keys, item: &Value, partition_key: &str) { + match item.get(partition_key).and_then(scalar_string) { + Some(v) => keys.add_leading(v), + // An item without its partition key is rejected by the handler, but + // it is never a known empty set. + None => keys.leading = None, + } +} + +fn add_item_attributes(keys: &mut Keys, item: &Value) { + if let Some(obj) = item.as_object() { + keys.attributes.extend(obj.keys().cloned()); + } +} + +fn expression_names(body: &Value) -> BTreeMap { + body["ExpressionAttributeNames"] + .as_object() + .into_iter() + .flatten() + .filter_map(|(k, v)| Some((k.clone(), v.as_str()?.to_string()))) + .collect() +} + +/// Attributes named by a request's projections, expressions and legacy +/// condition parameters. +fn add_request_attributes(keys: &mut Keys, body: &Value, names: &BTreeMap) { + if let Some(update) = body["UpdateExpression"].as_str() { + keys.attributes.extend(update_targets(update, names)); + } + for expr in [ + "ProjectionExpression", + "UpdateExpression", + "ConditionExpression", + "FilterExpression", + "KeyConditionExpression", + ] { + if let Some(text) = body[expr].as_str() { + keys.attributes + .extend(expression_attributes(text, names, false)); + } + } + for list in ["AttributesToGet"] { + for name in body[list].as_array().into_iter().flatten() { + if let Some(n) = name.as_str() { + keys.attributes.insert(n.to_string()); + } + } + } + for map in [ + "AttributeUpdates", + "Expected", + "KeyConditions", + "QueryFilter", + "ScanFilter", + ] { + if let Some(obj) = body[map].as_object() { + keys.attributes.extend(obj.keys().cloned()); + } + } +} + +/// The attributes an update expression may write. Each target is reported +/// both as its first path segment (`SET a.b = ...` writes inside `a`) and as +/// its whole text with names resolved: depending on its shape the executor +/// can also write a top-level attribute literally named `a.b[0]`, or split a +/// quoted `"a.b"` into a path. Reporting both can only narrow what an +/// attribute allow-list admits. +fn update_targets(expr: &str, names: &BTreeMap) -> Vec { + use super::helpers::{parse_update_clauses, UpdateAction}; + let resolve = |segment: &str| { + let segment = segment.trim().trim_matches('"'); + names + .get(segment) + .cloned() + .unwrap_or_else(|| segment.to_string()) + }; + let mut out = Vec::new(); + for (action, assignments) in parse_update_clauses(expr) { + for assignment in &assignments { + let target = match action { + UpdateAction::Set => match assignment.split_once('=') { + Some((left, _)) => left, + None => continue, + }, + UpdateAction::Remove => assignment.as_str(), + UpdateAction::Add | UpdateAction::Delete => { + assignment.split_whitespace().next().unwrap_or_default() + } + } + .trim(); + if target.is_empty() { + continue; + } + let unquoted = target.trim_matches('"'); + let first = unquoted.split(['.', '[']).next().unwrap_or(unquoted); + out.push(resolve(first)); + let whole = unquoted + .split('.') + .map(|segment| match segment.split_once('[') { + Some((name, index)) => format!("{}[{index}", resolve(name)), + None => resolve(segment), + }) + .collect::>() + .join("."); + out.push(whole); + } + } + out +} + +fn implicit_select(body: &Value, index: bool) -> String { + if let Some(select) = body["Select"].as_str() { + return select.to_string(); + } + if body["ProjectionExpression"].is_string() || body["AttributesToGet"].is_array() { + "SPECIFIC_ATTRIBUTES".to_string() + } else if index { + "ALL_PROJECTED_ATTRIBUTES".to_string() + } else { + "ALL_ATTRIBUTES".to_string() + } +} + +/// A token of a DynamoDB or PartiQL expression. +#[derive(Debug, PartialEq)] +enum Token { + /// An attribute reference or keyword, with whether it directly follows a + /// `.` (a nested path segment) and whether it was double-quoted. + Name { + text: String, + nested: bool, + quoted: bool, + }, + /// A `:placeholder`, a `?` parameter, a string or number literal. + Value, + Symbol(char), +} + +fn tokenize(text: &str) -> Vec { + let chars: Vec = text.chars().collect(); + let mut out = Vec::new(); + let mut i = 0; + while i < chars.len() { + let c = chars[i]; + let nested = matches!(out.last(), Some(Token::Symbol('.'))); + if c.is_whitespace() { + i += 1; + } else if c == '\'' { + // A PartiQL string literal; '' escapes a quote. + i += 1; + while i < chars.len() { + if chars[i] == '\'' { + if chars.get(i + 1) == Some(&'\'') { + i += 2; + continue; + } + break; + } + i += 1; + } + i += 1; + out.push(Token::Value); + } else if c == '"' { + let start = i + 1; + i = start; + while i < chars.len() && chars[i] != '"' { + i += 1; + } + out.push(Token::Name { + text: chars[start..i.min(chars.len())].iter().collect(), + nested, + quoted: true, + }); + i += 1; + } else if c == ':' || c.is_ascii_digit() || c == '?' { + i += 1; + while i < chars.len() && (chars[i].is_alphanumeric() || matches!(chars[i], '_' | '.')) { + if chars[i] == '.' && c != ':' && !chars[i - 1].is_ascii_digit() { + break; + } + i += 1; + } + out.push(Token::Value); + } else if c.is_alphabetic() || c == '_' || c == '#' { + let start = i; + i += 1; + while i < chars.len() && (chars[i].is_alphanumeric() || matches!(chars[i], '_' | '-')) { + i += 1; + } + out.push(Token::Name { + text: chars[start..i].iter().collect(), + nested, + quoted: false, + }); + } else { + out.push(Token::Symbol(c)); + i += 1; + } + } + out +} + +/// Top-level attribute names an expression references: every name that is +/// not a keyword, a function call, a nested path segment or a list index. In +/// a PartiQL statement (`partiql`) the table -- and a `"table"."index"` index +/// -- after `FROM` / `INTO` / `UPDATE` is not an attribute either. +fn expression_attributes( + text: &str, + names: &BTreeMap, + partiql: bool, +) -> Vec { + let tokens = tokenize(text); + let mut out = Vec::new(); + let mut i = 0; + while i < tokens.len() { + let Token::Name { + text, + nested, + quoted, + } = &tokens[i] + else { + i += 1; + continue; + }; + let lower = text.to_ascii_lowercase(); + if partiql && !quoted && matches!(lower.as_str(), "from" | "into" | "update") { + // The keyword and the table name, then an optional `.index`. + i += 2; + if matches!(tokens.get(i), Some(Token::Symbol('.'))) { + i += 2; + } + continue; + } + let keyword = !quoted + && (EXPRESSION_WORDS.contains(&lower.as_str()) + || (!partiql && UPDATE_CLAUSE_WORDS.contains(&lower.as_str()))); + let function = matches!(tokens.get(i + 1), Some(Token::Symbol('('))); + if !nested && !keyword && !function { + if let Some(name) = text.strip_prefix('#').map(|_| names.get(text)) { + if let Some(resolved) = name { + out.push(resolved.clone()); + } + } else { + out.push(text.clone()); + } + } + i += 1; + } + out +} + +/// The value a Query's key condition fixes the partition key to, found the +/// way the handler evaluates the condition: split on top-level AND, strip +/// one layer of enclosing parentheses, recurse. +fn query_partition_value( + body: &Value, + names: &BTreeMap, + partition_key: &str, +) -> Option { + if let Some(cond) = body["KeyConditions"][partition_key].as_object() { + return cond + .get("AttributeValueList")? + .as_array()? + .first() + .and_then(scalar_string); + } + let text = body["KeyConditionExpression"].as_str()?; + key_condition_partition_value( + text, + names, + &body["ExpressionAttributeValues"], + partition_key, + ) +} + +fn key_condition_partition_value( + expr: &str, + names: &BTreeMap, + values: &Value, + partition_key: &str, +) -> Option { + use super::helpers::{split_on_and, strip_outer_parens}; + let trimmed = expr.trim(); + let parts = split_on_and(trimmed); + if parts.len() > 1 { + return parts + .iter() + .find_map(|part| key_condition_partition_value(part, names, values, partition_key)); + } + let stripped = strip_outer_parens(trimmed); + if stripped != trimmed { + return key_condition_partition_value(stripped, names, values, partition_key); + } + if trimmed.to_ascii_lowercase().starts_with("begins_with") { + return None; + } + let (op, pos) = ["<=", ">=", "<>", "=", "<", ">"] + .iter() + .find_map(|cand| trimmed.find(cand).map(|pos| (*cand, pos)))?; + if op != "=" { + return None; + } + let left = trimmed[..pos].trim().trim_matches('"'); + let right = trimmed[pos + 1..].trim(); + if !right.starts_with(':') || right.contains(char::is_whitespace) { + return None; + } + let attr = if left.starts_with('#') { + names.get(left).map(String::as_str) + } else { + Some(left) + }; + if attr == Some(partition_key) { + return values.get(right).and_then(scalar_string); + } + None +} + +/// The PartiQL condition keys for one statement, parsed exactly the way the +/// executor parses it -- the same clause splitting, the same `?` parameter +/// binding, the same WHERE parser and item parser -- so a statement the +/// executor accepts cannot be read here as touching different partitions or +/// attributes than it does. +fn add_partiql_keys( + keys: &mut Keys, + target: &Target, + statement: &str, + parameters: &[Value], + verb: &str, +) { + use super::helpers::count_params_in_str; + use super::helpers::partiql::{ + parse_partiql_table_name, parse_partiql_value_object, partiql_expr_attributes, + partiql_pinned_values, partiql_where_conditions, split_partiql_returning_clause, + }; + + let trimmed = statement.trim(); + let upper = trimmed.to_ascii_uppercase(); + let mut conditions = None; + match verb { + "PartiQLInsert" => { + let Some(into) = find_outside_quotes(&upper, "INTO") else { + return; + }; + let (_, rest) = parse_partiql_table_name(trimmed[into + 4..].trim()); + let rest_upper = rest.trim().to_ascii_uppercase(); + let Some(value_pos) = find_outside_quotes(&rest_upper, "VALUE") else { + return; + }; + let value_str = rest.trim()[value_pos + 5..].trim(); + match parse_partiql_value_object(value_str, parameters) { + Ok(item) => { + match item.get(&target.partition_key).and_then(scalar_string) { + Some(v) => keys.add_leading(v), + None => keys.leading = None, + } + keys.attributes.extend(item.keys().cloned()); + } + Err(_) => keys.leading = None, + } + return; + } + "PartiQLSelect" => { + let Some(from) = find_outside_quotes(&upper, "FROM") else { + return; + }; + let projection = trimmed["SELECT".len()..from].trim(); + match super::helpers::partiql::partiql_column_paths(projection) { + Ok(Some(paths)) => { + for path in paths { + if let Some(super::helpers::partiql::PathSegment::Name(name)) = path.first() + { + keys.attributes.insert(name.clone()); + } + } + keys.merge_select("SPECIFIC_ATTRIBUTES".to_string()); + } + Ok(None) => keys.merge_select(if target.index.is_some() { + "ALL_PROJECTED_ATTRIBUTES".to_string() + } else { + "ALL_ATTRIBUTES".to_string() + }), + // The executor rejects the statement. + Err(_) => return, + } + let (_, mut rest) = parse_partiql_table_name(trimmed[from + 4..].trim()); + if let Some(index_part) = rest.strip_prefix('.') { + rest = parse_partiql_table_name(index_part).1; + } + if rest.trim().to_ascii_uppercase().starts_with("WHERE") { + conditions = partiql_where_conditions(rest.trim()[5..].trim(), parameters); + } + } + "PartiQLUpdate" => { + let (_, rest) = parse_partiql_table_name(trimmed[6..].trim()); + let rest_upper = rest.trim().to_ascii_uppercase(); + let Some(set_pos) = find_outside_quotes(&rest_upper, "SET") else { + return; + }; + let (after_set, _) = split_partiql_returning_clause(rest.trim()[set_pos + 3..].trim()); + let (set_clause, where_clause) = + match find_outside_quotes(&after_set.to_ascii_uppercase(), "WHERE") { + Some(wp) => (&after_set[..wp], after_set[wp + 5..].trim()), + None => (after_set, ""), + }; + let (update_expression, _) = + super::helpers::partiql::prepare_partiql_update_expression(set_clause, parameters); + keys.attributes + .extend(update_targets(&update_expression, &BTreeMap::new())); + // Attributes the assignments read; an extra name only narrows what + // an attribute allow-list admits. + keys.attributes + .extend(expression_attributes(set_clause, &BTreeMap::new(), true)); + let set_params = count_params_in_str(set_clause); + let where_params = parameters.get(set_params..).unwrap_or(&[]); + conditions = partiql_where_conditions(where_clause, where_params); + } + "PartiQLDelete" => { + let Some(from) = find_outside_quotes(&upper, "FROM") else { + return; + }; + let (_, rest) = parse_partiql_table_name(trimmed[from + 4..].trim()); + if rest.trim().to_ascii_uppercase().starts_with("WHERE") { + conditions = partiql_where_conditions(rest.trim()[5..].trim(), parameters); + } + } + _ => return, + } + if let Some(expr) = &conditions { + let mut attrs = Vec::new(); + partiql_expr_attributes(expr, &mut attrs); + keys.attributes.extend(attrs); + } + let pinned = conditions + .as_ref() + .and_then(|expr| partiql_pinned_values(expr, &target.partition_key)); + match &pinned { + Some(values) => { + for value in values { + match scalar_string(value) { + Some(v) => keys.add_leading(v), + None => keys.leading = None, + } + } + } + // An UPDATE or DELETE always pins its item (the executor rejects any + // other WHERE); a SELECT that pins nothing reads the whole table. + None if verb != "PartiQLSelect" => keys.leading = None, + None => {} + } + if verb == "PartiQLSelect" { + // Any statement of a batch or transaction scanning the table makes the + // request a full table scan. + let scans = pinned.is_none(); + keys.full_table_scan = Some(keys.full_table_scan.unwrap_or(false) || scans); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn names(pairs: &[(&str, &str)]) -> BTreeMap { + pairs + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect() + } + + #[test] + fn expression_attributes_are_top_level_names() { + let n = names(&[("#s", "status"), ("#d", "detail")]); + let mut got = expression_attributes( + "SET #s = :v, Address.City = :c, tags[0] = :t REMOVE #d.x ADD score :one", + &n, + false, + ); + got.sort(); + assert_eq!(got, ["Address", "detail", "score", "status", "tags"]); + + let mut got = expression_attributes( + "attribute_exists(pk) AND begins_with(sk, :p) OR size(items) > :n", + &n, + false, + ); + got.sort(); + assert_eq!(got, ["items", "pk", "sk"]); + } + + #[test] + fn partiql_set_clause_attributes_skip_literals() { + let mut got = expression_attributes( + "x = y + 1, \"note\" = 'set when? size', modified = ?", + &BTreeMap::new(), + true, + ); + got.sort(); + assert_eq!(got, ["modified", "note", "x", "y"]); + } + + #[test] + fn query_partition_value_reads_the_key_condition() { + let body = serde_json::json!({ + "KeyConditionExpression": "#p = :pk AND sk BETWEEN :a AND :b", + "ExpressionAttributeNames": {"#p": "pk"}, + "ExpressionAttributeValues": {":pk": {"S": "user-1"}, ":a": {"S": "a"}, ":b": {"S": "b"}} + }); + assert_eq!( + query_partition_value(&body, &expression_names(&body), "pk"), + Some("user-1".to_string()) + ); + let legacy = serde_json::json!({ + "KeyConditions": {"pk": {"ComparisonOperator": "EQ", "AttributeValueList": [{"N": "7"}]}} + }); + assert_eq!( + query_partition_value(&legacy, &BTreeMap::new(), "pk"), + Some("7".to_string()) + ); + } + + fn service_with_table() -> (crate::DynamoDbService, SharedDynamoDbState) { + let state: SharedDynamoDbState = std::sync::Arc::new(parking_lot::RwLock::new( + fakecloud_core::multi_account::MultiAccountState::new("123456789012", "us-east-1", ""), + )); + let svc = crate::DynamoDbService::new(state.clone()); + let req = request( + "CreateTable", + serde_json::json!({ + "TableName": "Games", + "KeySchema": [ + {"AttributeName": "UserId", "KeyType": "HASH"}, + {"AttributeName": "Title", "KeyType": "RANGE"} + ], + "AttributeDefinitions": [ + {"AttributeName": "UserId", "AttributeType": "S"}, + {"AttributeName": "Title", "AttributeType": "S"}, + {"AttributeName": "Top", "AttributeType": "N"} + ], + "GlobalSecondaryIndexes": [{ + "IndexName": "by-top", + "KeySchema": [{"AttributeName": "Title", "KeyType": "HASH"}, {"AttributeName": "Top", "KeyType": "RANGE"}], + "Projection": {"ProjectionType": "ALL"} + }], + "BillingMode": "PAY_PER_REQUEST" + }), + ); + svc.create_table(&req).unwrap(); + (svc, state) + } + + fn request(action: &str, body: Value) -> AwsRequest { + AwsRequest { + service: "dynamodb".to_string(), + action: action.to_string(), + region: "us-east-1".to_string(), + account_id: "123456789012".to_string(), + request_id: "id".to_string(), + headers: http::HeaderMap::new(), + query_params: std::collections::HashMap::new(), + body: serde_json::to_vec(&body).unwrap().into(), + body_stream: parking_lot::Mutex::new(None), + path_segments: vec![], + raw_path: "/".to_string(), + raw_query: String::new(), + method: http::Method::POST, + is_query_protocol: false, + access_key_id: None, + principal: None, + } + } + + /// Condition keys for every authorization a request needs, by action. + fn keys_for( + state: &SharedDynamoDbState, + action: &str, + body: Value, + ) -> Vec<(String, BTreeMap>)> { + let req = request(action, body); + super::super::iam::actions_for(state, &req) + .into_iter() + .map(|a| { + let keys = condition_keys(state, &req, &a); + (a.action.to_string(), keys) + }) + .collect() + } + + fn get<'a>(keys: &'a BTreeMap>, key: &str) -> Option<&'a [String]> { + keys.get(key).map(Vec::as_slice) + } + + #[test] + fn single_item_reads_and_writes() { + let (_svc, state) = service_with_table(); + let got = keys_for( + &state, + "GetItem", + serde_json::json!({ + "TableName": "Games", + "Key": {"UserId": {"S": "alice"}, "Title": {"S": "chess"}}, + "ProjectionExpression": "#t, Stats.Wins", + "ExpressionAttributeNames": {"#t": "Top"} + }), + ); + let keys = &got[0].1; + assert_eq!( + get(keys, "dynamodb:leadingkeys"), + Some(&["alice".to_string()][..]) + ); + assert_eq!( + get(keys, "dynamodb:firstpartitionkeyvalues"), + get(keys, "dynamodb:leadingkeys") + ); + assert_eq!( + get(keys, "dynamodb:attributes"), + Some( + &[ + "Stats".to_string(), + "Title".to_string(), + "Top".to_string(), + "UserId".to_string() + ][..] + ) + ); + assert_eq!( + get(keys, "dynamodb:select"), + Some(&["SPECIFIC_ATTRIBUTES".to_string()][..]) + ); + assert_eq!( + get(keys, "dynamodb:returnconsumedcapacity"), + Some(&["NONE".to_string()][..]) + ); + assert_eq!(get(keys, "dynamodb:returnvalues"), None); + + let got = keys_for( + &state, + "UpdateItem", + serde_json::json!({ + "TableName": "Games", + "Key": {"UserId": {"S": "bob"}, "Title": {"S": "go"}}, + "UpdateExpression": "SET Wins = Wins + :one", + "ConditionExpression": "attribute_exists(Losses)", + "ReturnValues": "ALL_NEW" + }), + ); + let keys = &got[0].1; + assert_eq!( + get(keys, "dynamodb:leadingkeys"), + Some(&["bob".to_string()][..]) + ); + assert_eq!( + get(keys, "dynamodb:attributes"), + Some( + &[ + "Losses".to_string(), + "Title".to_string(), + "UserId".to_string(), + "Wins".to_string() + ][..] + ) + ); + assert_eq!( + get(keys, "dynamodb:returnvalues"), + Some(&["ALL_NEW".to_string()][..]) + ); + assert_eq!(get(keys, "dynamodb:select"), None); + } + + #[test] + fn query_uses_the_index_partition_key_and_scan_has_no_leading_keys() { + let (_svc, state) = service_with_table(); + let got = keys_for( + &state, + "Query", + serde_json::json!({ + "TableName": "Games", + "IndexName": "by-top", + "KeyConditionExpression": "Title = :t AND Top > :n", + "ExpressionAttributeValues": {":t": {"S": "chess"}, ":n": {"N": "10"}} + }), + ); + let keys = &got[0].1; + assert_eq!( + get(keys, "dynamodb:leadingkeys"), + Some(&["chess".to_string()][..]) + ); + assert_eq!( + get(keys, "dynamodb:select"), + Some(&["ALL_PROJECTED_ATTRIBUTES".to_string()][..]) + ); + + let got = keys_for(&state, "Scan", serde_json::json!({"TableName": "Games"})); + let keys = &got[0].1; + assert_eq!(get(keys, "dynamodb:leadingkeys"), Some(&[][..])); + assert_eq!( + get(keys, "dynamodb:select"), + Some(&["ALL_ATTRIBUTES".to_string()][..]) + ); + } + + #[test] + fn transactions_carry_their_items_and_enclosing_operation() { + let (_svc, state) = service_with_table(); + let got = keys_for( + &state, + "TransactWriteItems", + serde_json::json!({"TransactItems": [ + {"Put": {"TableName": "Games", "Item": {"UserId": {"S": "a"}, "Title": {"S": "x"}}}}, + {"Put": {"TableName": "Games", "Item": {"UserId": {"S": "b"}, "Title": {"S": "y"}}}}, + {"Delete": {"TableName": "Games", "Key": {"UserId": {"S": "c"}, "Title": {"S": "z"}}}} + ]}), + ); + let put = &got.iter().find(|(a, _)| a == "PutItem").unwrap().1; + assert_eq!( + get(put, "dynamodb:leadingkeys"), + Some(&["a".to_string(), "b".to_string()][..]) + ); + assert_eq!( + get(put, "dynamodb:enclosingoperation"), + Some(&["TransactWriteItems".to_string()][..]) + ); + let delete = &got.iter().find(|(a, _)| a == "DeleteItem").unwrap().1; + assert_eq!( + get(delete, "dynamodb:leadingkeys"), + Some(&["c".to_string()][..]) + ); + } + + #[test] + fn partiql_select_reports_full_table_scans_and_leading_keys() { + let (_svc, state) = service_with_table(); + let got = keys_for( + &state, + "ExecuteStatement", + serde_json::json!({ + "Statement": "SELECT Top FROM \"Games\" WHERE UserId = ? AND Title = 'chess'", + "Parameters": [{"S": "alice"}] + }), + ); + let keys = &got[0].1; + assert_eq!( + get(keys, "dynamodb:leadingkeys"), + Some(&["alice".to_string()][..]) + ); + assert_eq!( + get(keys, "dynamodb:fulltablescan"), + Some(&["false".to_string()][..]) + ); + assert_eq!( + get(keys, "dynamodb:select"), + Some(&["SPECIFIC_ATTRIBUTES".to_string()][..]) + ); + + let got = keys_for( + &state, + "ExecuteTransaction", + serde_json::json!({"TransactStatements": [ + {"Statement": "SELECT * FROM \"Games\" WHERE Top > 3"} + ]}), + ); + let keys = &got[0].1; + assert_eq!( + get(keys, "dynamodb:fulltablescan"), + Some(&["true".to_string()][..]) + ); + assert_eq!( + get(keys, "dynamodb:enclosingoperation"), + Some(&["ExecuteTransaction".to_string()][..]) + ); + + let got = keys_for( + &state, + "ExecuteStatement", + serde_json::json!({"Statement": "INSERT INTO \"Games\" VALUE {'UserId': 'carol', 'Title': 't'}"}), + ); + assert_eq!( + get(&got[0].1, "dynamodb:leadingkeys"), + Some(&["carol".to_string()][..]) + ); + } + + /// The resource authorized for an existing table is its own stored ARN, + /// whatever region the request was signed for or the caller wrote into a + /// table ARN: that table is what the handler serves. + #[test] + fn authorization_uses_the_stored_table_arn() { + let (_svc, state) = service_with_table(); + let stored = "arn:aws:dynamodb:us-east-1:123456789012:table/Games"; + let mut req = request("GetItem", serde_json::json!({"TableName": "Games"})); + req.region = "eu-west-1".to_string(); + assert_eq!( + super::super::iam::actions_for(&state, &req)[0].resource, + stored + ); + let req = request( + "GetItem", + serde_json::json!({"TableName": "arn:aws:dynamodb:eu-west-1:123456789012:table/Games"}), + ); + assert_eq!( + super::super::iam::actions_for(&state, &req)[0].resource, + stored + ); + } + + /// Key conditions the Query handler accepts -- parenthesized, or with + /// tabs and newlines around AND -- still yield the partition key. + #[test] + fn query_partition_keys_follow_the_handler_parser() { + let (_svc, state) = service_with_table(); + for expr in [ + "(UserId = :u)", + "UserId = :u\nAND begins_with(Title, :t)", + "(UserId = :u)\tAND\t(Title = :t)", + ] { + let got = keys_for( + &state, + "Query", + serde_json::json!({ + "TableName": "Games", + "KeyConditionExpression": expr, + "ExpressionAttributeValues": {":u": {"S": "victim"}, ":t": {"S": "x"}} + }), + ); + assert_eq!( + get(&got[0].1, "dynamodb:leadingkeys"), + Some(&["victim".to_string()][..]), + "{expr}" + ); + } + } + + /// Every partition a PartiQL WHERE clause can reach is reported: OR and + /// IN widen the set, an unconstrained clause is a full table scan, and a + /// keyword inside an identifier or a `?` inside a string literal does not + /// throw the parse off. + #[test] + fn partiql_where_clauses_report_every_partition_they_reach() { + let (_svc, state) = service_with_table(); + let leading = |statement: &str, params: Value| { + let got = keys_for( + &state, + "ExecuteStatement", + serde_json::json!({"Statement": statement, "Parameters": params}), + ); + let keys = got[0].1.clone(); + ( + get(&keys, "dynamodb:leadingkeys").map(|v| v.to_vec()), + get(&keys, "dynamodb:fulltablescan").map(|v| v[0].clone()), + ) + }; + assert_eq!( + leading( + "SELECT * FROM \"Games\" WHERE UserId = 'mine' OR UserId = 'victim'", + Value::Null + ), + ( + Some(vec!["mine".to_string(), "victim".to_string()]), + Some("false".to_string()) + ) + ); + assert_eq!( + leading( + "SELECT * FROM \"Games\" WHERE UserId = 'mine' OR Top > 3", + Value::Null + ), + (Some(vec![]), Some("true".to_string())) + ); + assert_eq!( + leading( + "SELECT * FROM \"Games\" WHERE UserId IN ['victim']", + Value::Null + ) + .0, + Some(vec!["victim".to_string()]) + ); + assert_eq!( + leading( + "SELECT * FROM \"Games\" WHERE (UserId = 'victim')", + Value::Null + ) + .0, + Some(vec!["victim".to_string()]) + ); + assert_eq!( + leading( + "SELECT somewhere FROM \"Games\" WHERE UserId = 'victim'", + Value::Null + ) + .0, + Some(vec!["victim".to_string()]) + ); + let got = keys_for( + &state, + "ExecuteStatement", + serde_json::json!({ + "Statement": "UPDATE \"Games\" SET note = 'why?' WHERE UserId = ? AND Title = 't'", + "Parameters": [{"S": "victim"}] + }), + ); + assert_eq!( + get(&got[0].1, "dynamodb:leadingkeys"), + Some(&["victim".to_string()][..]) + ); + assert_eq!( + get(&got[0].1, "dynamodb:attributes"), + Some( + &[ + "Title".to_string(), + "UserId".to_string(), + "note".to_string() + ][..] + ) + ); + } + + /// An INSERT's partition key and attributes come from the item as the + /// executor parses it, not from the first `'UserId'` in the text. + #[test] + fn partiql_insert_reads_the_item() { + let (_svc, state) = service_with_table(); + let value = "{'note': 'UserId', 'UserId': 'victim', 'Title': 't', 'secret': 'x'}"; + let got = keys_for( + &state, + "ExecuteStatement", + serde_json::json!({"Statement": format!("INSERT INTO \"Games\" VALUE {value}")}), + ); + let keys = &got[0].1; + assert_eq!( + get(keys, "dynamodb:leadingkeys"), + Some(&["victim".to_string()][..]) + ); + // Exactly the attributes the executor's item parser finds. + let item = super::super::helpers::partiql::parse_partiql_value_object(value, &[]).unwrap(); + let mut expected: Vec = item.keys().cloned().collect(); + expected.sort(); + assert_eq!(get(keys, "dynamodb:attributes"), Some(&expected[..])); + assert!(expected.contains(&"secret".to_string())); + } + + /// A Scan on an index defaults to `ALL_PROJECTED_ATTRIBUTES`, like an + /// index Query. + #[test] + fn index_scan_default_select_is_all_projected() { + let (_svc, state) = service_with_table(); + let got = keys_for( + &state, + "Scan", + serde_json::json!({"TableName": "Games", "IndexName": "by-top"}), + ); + assert_eq!( + get(&got[0].1, "dynamodb:select"), + Some(&["ALL_PROJECTED_ATTRIBUTES".to_string()][..]) + ); + } + + /// A table ARN naming another account still authorizes the caller's own + /// table: that is the table the handler serves. + #[test] + fn a_foreign_account_arn_authorizes_the_callers_table() { + let (_svc, state) = service_with_table(); + let req = request( + "GetItem", + serde_json::json!({"TableName": "arn:aws:dynamodb:us-east-1:444455556666:table/Games"}), + ); + assert_eq!( + super::super::iam::actions_for(&state, &req)[0].resource, + "arn:aws:dynamodb:us-east-1:123456789012:table/Games" + ); + } + + /// A key condition parenthesized as a whole still yields its partition + /// key, a number key is reported canonically, and a condition the + /// extraction cannot read leaves the key unknown (omitted) rather than an + /// empty set a `ForAllValues` would accept. + #[test] + fn leading_keys_are_known_values_known_empty_or_omitted() { + let (_svc, state) = service_with_table(); + let got = keys_for( + &state, + "Query", + serde_json::json!({ + "TableName": "Games", + "KeyConditionExpression": "(UserId = :u AND Title = :t)", + "ExpressionAttributeValues": {":u": {"S": "victim"}, ":t": {"S": "x"}} + }), + ); + assert_eq!( + get(&got[0].1, "dynamodb:leadingkeys"), + Some(&["victim".to_string()][..]) + ); + + let got = keys_for( + &state, + "Query", + serde_json::json!({ + "TableName": "Games", + "KeyConditionExpression": "UserId = :u", + "ExpressionAttributeValues": {} + }), + ); + assert_eq!( + get(&got[0].1, "dynamodb:leadingkeys"), + None, + "unknown is omitted" + ); + + let got = keys_for(&state, "Scan", serde_json::json!({"TableName": "Games"})); + assert_eq!( + get(&got[0].1, "dynamodb:leadingkeys"), + Some(&[][..]), + "a scan pins none" + ); + assert_eq!(get(&got[0].1, "dynamodb:attributes"), Some(&[][..])); + + let got = keys_for( + &state, + "ExecuteStatement", + serde_json::json!({"Statement": "SELECT * FROM \"Games\" WHERE UserId = 1.0"}), + ); + assert_eq!( + get(&got[0].1, "dynamodb:leadingkeys"), + Some(&["1".to_string()][..]) + ); + } + + /// One scanning statement makes a batch or transaction a full table scan, + /// whatever order the statements come in; dotted attribute names are + /// reported whole, as the executor reads them. + #[test] + fn full_table_scan_and_dotted_names_across_statements() { + let (_svc, state) = service_with_table(); + let got = keys_for( + &state, + "ExecuteTransaction", + serde_json::json!({"TransactStatements": [ + {"Statement": "SELECT * FROM \"Games\" WHERE Top > 3"}, + {"Statement": "SELECT * FROM \"Games\" WHERE UserId = 'mine' AND \"a.b\" = 1"} + ]}), + ); + let keys = &got[0].1; + assert_eq!( + get(keys, "dynamodb:fulltablescan"), + Some(&["true".to_string()][..]) + ); + let attrs = get(keys, "dynamodb:attributes").unwrap(); + assert!(attrs.contains(&"a.b".to_string()), "{attrs:?}"); + } + + /// SELECT and SET columns are read with the executor's parsers, so names + /// that look like keywords, start with a digit or carry `#`/`:` are all + /// reported; and `Select` is the most permissive across statements. + #[test] + fn partiql_columns_and_select_follow_the_executor() { + let (_svc, state) = service_with_table(); + let got = keys_for( + &state, + "ExecuteStatement", + serde_json::json!({ + "Statement": "SELECT size, value, 2fa_secret, #secret, \"a:b\" FROM \"Games\" WHERE UserId = 'mine'" + }), + ); + let attrs = get(&got[0].1, "dynamodb:attributes").unwrap().to_vec(); + for name in ["size", "value", "2fa_secret", "#secret", "a:b", "UserId"] { + assert!( + attrs.contains(&name.to_string()), + "{name} missing from {attrs:?}" + ); + } + + let got = keys_for( + &state, + "ExecuteStatement", + serde_json::json!({ + "Statement": "UPDATE \"Games\" SET modified = ?, \"new\" = 1 WHERE UserId = 'mine' AND Title = 't'", + "Parameters": [{"S": "x"}] + }), + ); + let attrs = get(&got[0].1, "dynamodb:attributes").unwrap().to_vec(); + assert!(attrs.contains(&"modified".to_string()), "{attrs:?}"); + assert!(attrs.contains(&"new".to_string()), "{attrs:?}"); + + let got = keys_for( + &state, + "BatchExecuteStatement", + serde_json::json!({"Statements": [ + {"Statement": "SELECT * FROM \"Games\" WHERE UserId = 'mine'"}, + {"Statement": "SELECT UserId FROM \"Games\" WHERE UserId = 'mine'"} + ]}), + ); + assert_eq!( + get(&got[0].1, "dynamodb:select"), + Some(&["ALL_ATTRIBUTES".to_string()][..]) + ); + + let got = keys_for( + &state, + "TransactGetItems", + serde_json::json!({"TransactItems": [ + {"Get": {"TableName": "Games", "Key": {"UserId": {"S": "a"}, "Title": {"S": "x"}}}}, + {"Get": {"TableName": "Games", "Key": {"UserId": {"S": "a"}, "Title": {"S": "y"}}, "ProjectionExpression": "UserId"}} + ]}), + ); + assert_eq!( + get(&got[0].1, "dynamodb:select"), + Some(&["ALL_ATTRIBUTES".to_string()][..]) + ); + } + + /// Every way the executor can read an update target is reported: the + /// first segment and the whole path, names resolved. + #[test] + fn update_targets_report_every_reading_of_a_path() { + let names: BTreeMap = [("#m".to_string(), "meta".to_string())] + .into_iter() + .collect(); + let mut got = update_targets( + "SET \"a.b\" = :v, x.y[0] = :w ADD #m.c :n REMOVE z[1][2]", + &names, + ); + got.sort(); + got.dedup(); + for want in ["a", "a.b", "x", "x.y[0]", "meta", "meta.c", "z", "z[1][2]"] { + assert!( + got.contains(&want.to_string()), + "{want} missing from {got:?}" + ); + } + } + + /// A plain placeholder target reports only the attribute it names. + #[test] + fn update_targets_resolve_placeholders_without_extra_names() { + let names: BTreeMap = [("#t".to_string(), "Top".to_string())] + .into_iter() + .collect(); + let mut got = update_targets("SET #t = :v, Wins = Wins + :one", &names); + got.sort(); + got.dedup(); + assert_eq!(got, ["Top", "Wins"]); + } +} diff --git a/crates/fakecloud-dynamodb/src/service/mod.rs b/crates/fakecloud-dynamodb/src/service/mod.rs index 30145eb4b..d32f6d4fa 100644 --- a/crates/fakecloud-dynamodb/src/service/mod.rs +++ b/crates/fakecloud-dynamodb/src/service/mod.rs @@ -2,6 +2,8 @@ mod batch; #[cfg(test)] mod expression_corpus_tests; mod global_tables; +pub(crate) mod iam; +mod iam_conditions; mod items; mod queries; mod streams; @@ -537,6 +539,38 @@ impl AwsService for DynamoDbService { result } + fn iam_enforceable(&self) -> bool { + true + } + + fn iam_actions_for(&self, request: &AwsRequest) -> Vec { + iam::actions_for(&self.state, request) + } + + fn iam_action_for(&self, request: &AwsRequest) -> Option { + iam::actions_for(&self.state, request).into_iter().next() + } + + fn iam_condition_keys_for( + &self, + request: &AwsRequest, + action: &fakecloud_core::auth::IamAction, + ) -> std::collections::BTreeMap> { + iam_conditions::condition_keys(&self.state, request, action) + } + + fn resource_tags_for(&self, resource_arn: &str) -> Option> { + iam::resource_tags(&self.state, resource_arn) + } + + fn request_tags_from( + &self, + request: &AwsRequest, + action: &str, + ) -> Option> { + iam::request_tags(request, action) + } + fn supported_actions(&self) -> &[&str] { &[ "CreateTable", diff --git a/crates/fakecloud-dynamodb/src/service/queries.rs b/crates/fakecloud-dynamodb/src/service/queries.rs index 2d16cce22..8506d7bba 100644 --- a/crates/fakecloud-dynamodb/src/service/queries.rs +++ b/crates/fakecloud-dynamodb/src/service/queries.rs @@ -862,7 +862,7 @@ fn resolve_select(body: &Value, is_index_query: bool) -> Result, projection: &Projection, index_key_attrs: &[String], diff --git a/crates/fakecloud-dynamodb/src/service/tables.rs b/crates/fakecloud-dynamodb/src/service/tables.rs index ef8639453..709a0d7cb 100644 --- a/crates/fakecloud-dynamodb/src/service/tables.rs +++ b/crates/fakecloud-dynamodb/src/service/tables.rs @@ -184,6 +184,16 @@ impl DynamoDbService { .unwrap_or("STANDARD") .to_string(); + // A `ResourcePolicy` given at creation is attached to the table, as + // PutResourcePolicy would; it was accepted and dropped. + let create_resource_policy = match body["ResourcePolicy"].as_str() { + Some(policy) => { + validate_resource_policy_document(policy)?; + Some(policy.to_string()) + } + None => None, + }; + let mut accounts = self.state.write(); let state = accounts.get_or_create(&req.account_id); @@ -236,7 +246,7 @@ impl DynamoDbService { billing_mode: billing_mode.clone(), ttl_attribute: None, ttl_enabled: false, - resource_policy: None, + resource_policy: create_resource_policy, pitr_enabled: false, kinesis_destinations: Vec::new(), contributor_insights_status: "DISABLED".to_string(), @@ -295,6 +305,11 @@ impl DynamoDbService { format!("Requested resource not found: Table: {table_name} not found"), ) })?; + // Its streams' policies go with it. + let stream_prefix = format!("{}/stream/", table.arn); + state + .stream_policies + .retain(|arn, _| !arn.starts_with(&stream_prefix)); let table_desc = build_table_description_json(&super::TableDescriptionInput { arn: &table.arn, @@ -779,12 +794,14 @@ impl DynamoDbService { let body = Self::parse_body(req)?; let resource_arn = require_str(&body, "ResourceArn")?; let policy = require_str(&body, "Policy")?; + let expected = body["ExpectedRevisionId"].as_str(); + validate_resource_policy_document(policy)?; let mut accounts = self.state.write(); let state = accounts.get_or_create(&req.account_id); - let table = find_table_by_arn_mut(&mut state.tables, resource_arn)?; - table.resource_policy = Some(policy.to_string()); - + let mut slot = resource_policy_slot(state, resource_arn)?; + check_expected_revision(slot.current(), expected)?; + slot.set(policy.to_string()); Self::ok_json(json!({ "RevisionId": policy_revision_id(policy) })) } @@ -795,23 +812,16 @@ impl DynamoDbService { let body = Self::parse_body(req)?; let resource_arn = require_str(&body, "ResourceArn")?; - let accounts = self.state.read(); - let empty_ddb = crate::state::DynamoDbState::new(&req.account_id, &req.region); - let state = accounts.get(&req.account_id).unwrap_or(&empty_ddb); - let table = find_table_by_arn(&state.tables, resource_arn)?; - - match &table.resource_policy { + let mut accounts = self.state.write(); + let state = accounts.get_or_create(&req.account_id); + match resource_policy_slot(state, resource_arn)?.current() { Some(policy) => Self::ok_json(json!({ "Policy": policy, "RevisionId": policy_revision_id(policy) })), // DynamoDB is awsJson1.0 — client errors are HTTP 400 with the // error type in the body, never 404. - None => Err(AwsServiceError::aws_error( - StatusCode::BAD_REQUEST, - "PolicyNotFoundException", - "No resource-based policy is attached to the resource.", - )), + None => Err(policy_not_found()), } } @@ -821,13 +831,23 @@ impl DynamoDbService { ) -> Result { let body = Self::parse_body(req)?; let resource_arn = require_str(&body, "ResourceArn")?; + let expected = body["ExpectedRevisionId"].as_str(); let mut accounts = self.state.write(); let state = accounts.get_or_create(&req.account_id); - let table = find_table_by_arn_mut(&mut state.tables, resource_arn)?; - table.resource_policy = None; - - Self::ok_json(json!({})) + let mut slot = resource_policy_slot(state, resource_arn)?; + if expected.is_some() { + // A conditional delete needs a policy at that revision; an + // unconditional one is idempotent. + match slot.current() { + Some(current) if Some(policy_revision_id(current).as_str()) == expected => {} + _ => return Err(policy_not_found()), + } + } + match slot.take() { + Some(removed) => Self::ok_json(json!({ "RevisionId": policy_revision_id(&removed) })), + None => Self::ok_json(json!({})), + } } // ── Backups ───────────────────────────────────────────────────────── @@ -2015,3 +2035,124 @@ fn policy_revision_id(policy: &str) -> String { policy.hash(&mut h); format!("{:016x}", h.finish()) } + +/// A resource-based policy document DynamoDB can attach: JSON, at most 20 KB +/// counting whitespace. +fn validate_resource_policy_document(policy: &str) -> Result<(), AwsServiceError> { + const MAX_POLICY_BYTES: usize = 20 * 1024; + if policy.len() > MAX_POLICY_BYTES { + return Err(AwsServiceError::aws_error( + StatusCode::BAD_REQUEST, + "ValidationException", + format!( + "Resource-based policy document size {} bytes exceeds the maximum of {MAX_POLICY_BYTES} bytes", + policy.len() + ), + )); + } + if serde_json::from_str::(policy) + .map(|v| !v.is_object()) + .unwrap_or(true) + { + return Err(AwsServiceError::aws_error( + StatusCode::BAD_REQUEST, + "ValidationException", + "Resource-based policy document is not valid JSON", + )); + } + Ok(()) +} + +/// Where the policy for a table or stream ARN is kept: the table's own slot, +/// or the stream's entry in the account's stream-policy map. +enum PolicySlot<'a> { + Table(&'a mut Option), + Stream(&'a mut BTreeMap, String), +} + +impl PolicySlot<'_> { + fn current(&self) -> Option<&str> { + match self { + PolicySlot::Table(slot) => slot.as_deref(), + PolicySlot::Stream(map, arn) => map.get(arn).map(String::as_str), + } + } + + fn set(&mut self, policy: String) { + match self { + PolicySlot::Table(slot) => **slot = Some(policy), + PolicySlot::Stream(map, arn) => { + map.insert(arn.clone(), policy); + } + } + } + + fn take(&mut self) -> Option { + match self { + PolicySlot::Table(slot) => slot.take(), + PolicySlot::Stream(map, arn) => map.remove(arn.as_str()), + } + } +} + +/// The policy slot a `ResourceArn` names. A stream ARN has to be its table's +/// current stream. Any other ARN -- an index, a backup, a table or stream that +/// does not exist -- is `ResourceNotFoundException`. +fn resource_policy_slot<'a>( + state: &'a mut crate::state::DynamoDbState, + resource_arn: &str, +) -> Result, AwsServiceError> { + let not_found = || { + AwsServiceError::aws_error( + StatusCode::BAD_REQUEST, + "ResourceNotFoundException", + format!("Requested resource not found: {resource_arn}"), + ) + }; + let (table_part, stream) = match resource_arn.split_once("/stream/") { + Some((table_part, _)) => (table_part, true), + None => (resource_arn, false), + }; + let (name, stream_arn) = state + .tables + .iter() + .find(|(_, t)| t.arn == table_part) + .map(|(name, t)| (name.clone(), t.stream_arn.clone())) + .ok_or_else(not_found)?; + if !stream { + let table = state.tables.get_mut(&name).ok_or_else(not_found)?; + return Ok(PolicySlot::Table(&mut table.resource_policy)); + } + if stream_arn.as_deref() != Some(resource_arn) { + return Err(not_found()); + } + Ok(PolicySlot::Stream( + &mut state.stream_policies, + resource_arn.to_string(), + )) +} + +fn policy_not_found() -> AwsServiceError { + AwsServiceError::aws_error( + StatusCode::BAD_REQUEST, + "PolicyNotFoundException", + "No resource-based policy is attached to the resource.", + ) +} + +/// `ExpectedRevisionId` guards a policy write: `NO_POLICY` requires that no +/// policy is attached yet, any other value that the attached policy is at +/// that revision. A mismatch is `PolicyNotFoundException`, as on AWS. +fn check_expected_revision( + current: Option<&str>, + expected: Option<&str>, +) -> Result<(), AwsServiceError> { + match (expected, current) { + (None, _) => Ok(()), + (Some("NO_POLICY"), None) => Ok(()), + (Some(rev), Some(policy)) if rev != "NO_POLICY" && policy_revision_id(policy) == rev => { + Ok(()) + } + _ => Err(policy_not_found()), + } +} diff --git a/crates/fakecloud-dynamodb/src/service/tests.rs b/crates/fakecloud-dynamodb/src/service/tests.rs index 1f2d13fa6..83e89e05b 100644 --- a/crates/fakecloud-dynamodb/src/service/tests.rs +++ b/crates/fakecloud-dynamodb/src/service/tests.rs @@ -6783,3 +6783,355 @@ async fn backups_and_insights_listings_accept_a_table_arn() { "{listed}" ); } + +fn create_streamed_gsi_table(svc: &DynamoDbService, name: &str) -> Value { + let resp = svc + .create_table(&make_request( + "CreateTable", + json!({ + "TableName": name, + "KeySchema": [{"AttributeName": "pk", "KeyType": "HASH"}], + "AttributeDefinitions": [ + {"AttributeName": "pk", "AttributeType": "S"}, + {"AttributeName": "g", "AttributeType": "S"} + ], + "GlobalSecondaryIndexes": [{ + "IndexName": "by-g", + "KeySchema": [{"AttributeName": "g", "KeyType": "HASH"}], + "Projection": {"ProjectionType": "INCLUDE", "NonKeyAttributes": ["shown"]} + }], + "StreamSpecification": {"StreamEnabled": true, "StreamViewType": "NEW_IMAGE"}, + "BillingMode": "PAY_PER_REQUEST" + }), + )) + .unwrap(); + serde_json::from_slice::(resp.body.expect_bytes()).unwrap()["TableDescription"].clone() +} + +async fn err_code(svc: &DynamoDbService, action: &str, body: Value) -> Option { + svc.handle(make_request(action, body)) + .await + .err() + .map(|e| e.code().to_string()) +} + +const POLICY: &str = r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"arn:aws:iam::123456789012:root"},"Action":"dynamodb:GetItem","Resource":"*"}]}"#; + +/// PutResourcePolicy / DeleteResourcePolicy honor `ExpectedRevisionId` +/// (`NO_POLICY` meaning "only if none is attached"), a delete reports the +/// revision it removed and is idempotent without one, and a stream carries a +/// policy of its own. +#[tokio::test] +async fn resource_policy_revisions_and_stream_policies() { + let svc = make_service(); + let desc = create_streamed_gsi_table(&svc, "Orders"); + let table_arn = desc["TableArn"].as_str().unwrap().to_string(); + let stream_arn = desc["LatestStreamArn"].as_str().unwrap().to_string(); + + let put = call_dynamodb( + &svc, + "PutResourcePolicy", + json!({"ResourceArn": table_arn, "Policy": POLICY, "ExpectedRevisionId": "NO_POLICY"}), + ) + .await; + let revision = put["RevisionId"].as_str().unwrap().to_string(); + // Idempotent: the same document keeps its revision. + let again = call_dynamodb( + &svc, + "PutResourcePolicy", + json!({"ResourceArn": table_arn, "Policy": POLICY, "ExpectedRevisionId": revision}), + ) + .await; + assert_eq!(again["RevisionId"], put["RevisionId"]); + assert_eq!( + err_code( + &svc, + "PutResourcePolicy", + json!({"ResourceArn": table_arn, "Policy": POLICY, "ExpectedRevisionId": "NO_POLICY"}) + ) + .await + .as_deref(), + Some("PolicyNotFoundException"), + "NO_POLICY with a policy attached" + ); + assert_eq!( + err_code( + &svc, + "DeleteResourcePolicy", + json!({"ResourceArn": table_arn, "ExpectedRevisionId": "stale"}) + ) + .await + .as_deref(), + Some("PolicyNotFoundException") + ); + + // The stream's policy is separate from the table's. + let stream_policy = POLICY.replace("GetItem", "DescribeStream"); + call_dynamodb( + &svc, + "PutResourcePolicy", + json!({"ResourceArn": stream_arn, "Policy": stream_policy}), + ) + .await; + let got = call_dynamodb( + &svc, + "GetResourcePolicy", + json!({"ResourceArn": stream_arn}), + ) + .await; + assert_eq!(got["Policy"], json!(stream_policy)); + let got = call_dynamodb(&svc, "GetResourcePolicy", json!({"ResourceArn": table_arn})).await; + assert_eq!(got["Policy"], json!(POLICY)); + + let deleted = call_dynamodb( + &svc, + "DeleteResourcePolicy", + json!({"ResourceArn": table_arn, "ExpectedRevisionId": revision}), + ) + .await; + assert_eq!(deleted["RevisionId"], json!(revision)); + let deleted = call_dynamodb( + &svc, + "DeleteResourcePolicy", + json!({"ResourceArn": table_arn}), + ) + .await; + assert_eq!(deleted, json!({}), "an unconditional delete is idempotent"); + + // Only tables and their current streams carry policies. + for arn in [ + format!("{table_arn}/index/by-g"), + format!("{table_arn}/stream/2000-01-01T00:00:00.000"), + ] { + assert_eq!( + err_code( + &svc, + "PutResourcePolicy", + json!({"ResourceArn": arn, "Policy": POLICY}) + ) + .await + .as_deref(), + Some("ResourceNotFoundException"), + "{arn}" + ); + } + // Not JSON, and over 20 KB, are rejected. + for policy in [ + "not json".to_string(), + format!("{{\"a\":\"{}\"}}", "x".repeat(21 * 1024)), + ] { + assert_eq!( + err_code( + &svc, + "PutResourcePolicy", + json!({"ResourceArn": table_arn, "Policy": policy}) + ) + .await + .as_deref(), + Some("ValidationException") + ); + } + + // Deleting the table drops its streams' policies. + call_dynamodb(&svc, "DeleteTable", json!({"TableName": "Orders"})).await; + assert!(svc.state.read().default_ref().stream_policies.is_empty()); +} + +/// A policy given to CreateTable is attached to the new table. +#[tokio::test] +async fn create_table_attaches_its_resource_policy() { + let svc = make_service(); + svc.create_table(&make_request( + "CreateTable", + json!({ + "TableName": "WithPolicy", + "KeySchema": [{"AttributeName": "pk", "KeyType": "HASH"}], + "AttributeDefinitions": [{"AttributeName": "pk", "AttributeType": "S"}], + "BillingMode": "PAY_PER_REQUEST", + "ResourcePolicy": POLICY + }), + )) + .unwrap(); + let arn = svc.state.read().default_ref().tables["WithPolicy"] + .arn + .clone(); + let got = call_dynamodb(&svc, "GetResourcePolicy", json!({"ResourceArn": arn})).await; + assert_eq!(got["Policy"], json!(POLICY)); +} + +/// `SELECT ... FROM "table"."index"` reads the index: only rows carrying its +/// key, with only its projected attributes, and the WHERE clause applies. It +/// used to read the whole base table and skip the WHERE clause. +#[tokio::test] +async fn partiql_select_from_an_index_reads_the_index() { + let svc = make_service(); + create_streamed_gsi_table(&svc, "Orders"); + for item in [ + json!({"pk": {"S": "a"}, "g": {"S": "x"}, "shown": {"S": "1"}, "hidden": {"S": "h"}}), + json!({"pk": {"S": "b"}, "g": {"S": "y"}, "shown": {"S": "2"}}), + json!({"pk": {"S": "c"}, "hidden": {"S": "no index key"}}), + ] { + call_dynamodb( + &svc, + "PutItem", + json!({"TableName": "Orders", "Item": item}), + ) + .await; + } + + let all = call_dynamodb( + &svc, + "ExecuteStatement", + json!({"Statement": "SELECT * FROM \"Orders\".\"by-g\""}), + ) + .await; + let mut pks: Vec<&str> = all["Items"] + .as_array() + .unwrap() + .iter() + .map(|i| i["pk"]["S"].as_str().unwrap()) + .collect(); + pks.sort(); + assert_eq!(pks, ["a", "b"], "only rows carrying the index key"); + for item in all["Items"].as_array().unwrap() { + assert!( + item.get("hidden").is_none(), + "unprojected attribute: {item}" + ); + } + + let filtered = call_dynamodb( + &svc, + "ExecuteStatement", + json!({"Statement": "SELECT * FROM \"Orders\".\"by-g\" WHERE g = 'x'"}), + ) + .await; + assert_eq!( + filtered["Items"], + json!([{"pk": {"S": "a"}, "g": {"S": "x"}, "shown": {"S": "1"}}]) + ); + + let err = svc + .handle(make_request( + "ExecuteStatement", + json!({"Statement": "SELECT * FROM \"Orders\".\"nope\""}), + )) + .await + .err() + .unwrap(); + assert_eq!(err.code(), "ValidationException"); +} + +/// A PartiQL SELECT returns only the columns it names (document paths and +/// quoted names included); `*` returns the whole item. +#[tokio::test] +async fn partiql_select_returns_only_the_named_columns() { + let svc = make_service(); + create_test_table(&svc); + call_dynamodb( + &svc, + "PutItem", + json!({"TableName": "test-table", "Item": { + "pk": {"S": "a"}, + "public": {"S": "p"}, + "secret": {"S": "s"}, + "a.b": {"S": "dotted"}, + "addr": {"M": {"city": {"S": "c"}, "zip": {"S": "z"}}} + }}), + ) + .await; + let got = call_dynamodb( + &svc, + "ExecuteStatement", + json!({"Statement": "SELECT pk, public, \"a.b\", addr.city FROM \"test-table\" WHERE pk = 'a'"}), + ) + .await; + assert_eq!( + got["Items"], + json!([{ + "pk": {"S": "a"}, + "public": {"S": "p"}, + "a.b": {"S": "dotted"}, + "addr": {"M": {"city": {"S": "c"}}} + }]) + ); + let all = call_dynamodb( + &svc, + "ExecuteStatement", + json!({"Statement": "SELECT * FROM \"test-table\" WHERE pk = 'a'"}), + ) + .await; + assert_eq!(all["Items"][0]["secret"], json!({"S": "s"})); +} + +/// Column projection happens after paging, so the cursor still has each +/// row's full key: a column list without the key attributes pages through +/// every row once. An empty or unreadable column list is rejected instead of +/// returning more than was asked for. +#[tokio::test] +async fn partiql_select_columns_page_and_validate() { + let svc = make_service(); + svc.create_table(&make_request( + "CreateTable", + json!({ + "TableName": "Scores", + "KeySchema": [ + {"AttributeName": "u", "KeyType": "HASH"}, + {"AttributeName": "g", "KeyType": "RANGE"} + ], + "AttributeDefinitions": [ + {"AttributeName": "u", "AttributeType": "S"}, + {"AttributeName": "g", "AttributeType": "S"} + ], + "BillingMode": "PAY_PER_REQUEST" + }), + )) + .unwrap(); + for g in ["a", "b", "c"] { + call_dynamodb( + &svc, + "PutItem", + json!({"TableName": "Scores", "Item": {"u": {"S": "u1"}, "g": {"S": g}, "score": {"N": g.len().to_string()}}}), + ) + .await; + } + let mut seen = 0; + let mut token: Option = None; + loop { + let mut body = json!({"Statement": "SELECT score FROM \"Scores\"", "Limit": 1}); + if let Some(t) = &token { + body["NextToken"] = json!(t); + } + let page = call_dynamodb(&svc, "ExecuteStatement", body).await; + for item in page["Items"].as_array().unwrap() { + assert!(item.get("u").is_none() && item.get("g").is_none(), "{item}"); + seen += 1; + } + token = page["NextToken"].as_str().map(str::to_string); + if token.is_none() { + break; + } + } + assert_eq!(seen, 3, "every row once"); + + for statement in [ + "SELECT FROM \"Scores\"", + "SELECT tags[-1] FROM \"Scores\"", + "SELECT \"addr ,x FROM \"Scores\"", + ] { + assert_eq!( + err_code(&svc, "ExecuteStatement", json!({"Statement": statement})) + .await + .as_deref(), + Some("ValidationException"), + "{statement}" + ); + } + // Whitespace around path separators is fine. + call_dynamodb( + &svc, + "ExecuteStatement", + json!({"Statement": "SELECT \"u\" . \"x\", score [ 0 ] FROM \"Scores\""}), + ) + .await; +} diff --git a/crates/fakecloud-dynamodb/src/state.rs b/crates/fakecloud-dynamodb/src/state.rs index 5cc21628a..3bd5207ca 100644 --- a/crates/fakecloud-dynamodb/src/state.rs +++ b/crates/fakecloud-dynamodb/src/state.rs @@ -1383,6 +1383,12 @@ pub struct DynamoDbState { /// `#[serde(default)]` keeps older snapshots loadable. #[serde(default)] pub lambda_stream_checkpoints: BTreeMap, + /// Resource-based policies attached to DynamoDB streams, keyed by stream + /// ARN. A stream's policy is its own -- separate from its table's -- and + /// belongs to that stream: re-enabling a table's stream mints a new ARN + /// with no policy. + #[serde(default)] + pub stream_policies: BTreeMap, } /// On-disk snapshot envelope. The payload is the full [`DynamoDbState`]; @@ -1422,6 +1428,7 @@ impl DynamoDbState { exports: BTreeMap::new(), imports: BTreeMap::new(), lambda_stream_checkpoints: BTreeMap::new(), + stream_policies: BTreeMap::new(), } } @@ -1432,6 +1439,7 @@ impl DynamoDbState { self.exports.clear(); self.imports.clear(); self.lambda_stream_checkpoints.clear(); + self.stream_policies.clear(); } /// Last stream sequence number delivered for the given DynamoDB diff --git a/crates/fakecloud-dynamodb/src/streams_dataplane.rs b/crates/fakecloud-dynamodb/src/streams_dataplane.rs index 3a7947890..4a44ff622 100644 --- a/crates/fakecloud-dynamodb/src/streams_dataplane.rs +++ b/crates/fakecloud-dynamodb/src/streams_dataplane.rs @@ -46,6 +46,27 @@ impl AwsService for DynamoDbStreamsService { } } + fn iam_enforceable(&self) -> bool { + true + } + + fn iam_actions_for(&self, request: &AwsRequest) -> Vec { + crate::service::iam::streams_actions_for(request) + } + + fn iam_action_for(&self, request: &AwsRequest) -> Option { + crate::service::iam::streams_actions_for(request) + .into_iter() + .next() + } + + fn resource_tags_for( + &self, + resource_arn: &str, + ) -> Option> { + crate::service::iam::resource_tags(&self.state, resource_arn) + } + fn supported_actions(&self) -> &[&str] { &[ "ListStreams", diff --git a/crates/fakecloud-e2e/tests/iam_enforcement_dynamodb.rs b/crates/fakecloud-e2e/tests/iam_enforcement_dynamodb.rs new file mode 100644 index 000000000..dc920e60f --- /dev/null +++ b/crates/fakecloud-e2e/tests/iam_enforcement_dynamodb.rs @@ -0,0 +1,783 @@ +//! IAM enforcement for DynamoDB and DynamoDB Streams. +//! +//! Each test starts fakecloud with `FAKECLOUD_IAM=strict`, seeds tables with +//! the root-bypass `test` credentials, gives a user an inline policy, and +//! checks what that user's own credentials may do. + +mod helpers; + +use aws_credential_types::Credentials; +use aws_sdk_dynamodb::types::{ + AttributeDefinition, AttributeValue, BillingMode, GlobalSecondaryIndex, KeySchemaElement, + KeyType, Projection, ProjectionType, Put, PutRequest, ScalarAttributeType, StreamSpecification, + StreamViewType, Tag, TransactWriteItem, WriteRequest, +}; +use aws_sdk_dynamodb::Client as DynamoClient; +use aws_sdk_iam::Client as IamClient; +use helpers::TestServer; + +const ACCOUNT: &str = "123456789012"; +const REGION: &str = "us-east-1"; + +async fn start_strict() -> TestServer { + TestServer::start_with_env(&[ + ("FAKECLOUD_IAM", "strict"), + ("FAKECLOUD_VERIFY_SIGV4", "true"), + ]) + .await +} + +async fn sdk_config_with(server: &TestServer, akid: &str, secret: &str) -> aws_config::SdkConfig { + aws_config::defaults(aws_config::BehaviorVersion::latest()) + .endpoint_url(server.endpoint()) + .region(aws_config::Region::new(REGION)) + .credentials_provider(Credentials::new( + akid, + secret, + None, + None, + "fakecloud-dynamodb-iam", + )) + .load() + .await +} + +async fn admin(server: &TestServer) -> DynamoClient { + DynamoClient::new(&sdk_config_with(server, "test", "test").await) +} + +/// A user whose only permissions are `policy`, and a DynamoDB client signed +/// with that user's credentials. +async fn user_with_policy(server: &TestServer, name: &str, policy: &str) -> DynamoClient { + let boot = sdk_config_with(server, "test", "test").await; + let iam = IamClient::new(&boot); + iam.create_user().user_name(name).send().await.unwrap(); + let key = iam + .create_access_key() + .user_name(name) + .send() + .await + .unwrap(); + let key = key.access_key().unwrap(); + iam.put_user_policy() + .user_name(name) + .policy_name("inline") + .policy_document(policy) + .send() + .await + .unwrap(); + DynamoClient::new(&sdk_config_with(server, key.access_key_id(), key.secret_access_key()).await) +} + +fn table_arn(name: &str) -> String { + format!("arn:aws:dynamodb:{REGION}:{ACCOUNT}:table/{name}") +} + +fn allow(actions: &[&str], resources: &[String]) -> String { + serde_json::json!({ + "Version": "2012-10-17", + "Statement": [{"Effect": "Allow", "Action": actions, "Resource": resources}] + }) + .to_string() +} + +async fn create_table(client: &DynamoClient, name: &str) { + client + .create_table() + .table_name(name) + .key_schema( + KeySchemaElement::builder() + .attribute_name("pk") + .key_type(KeyType::Hash) + .build() + .unwrap(), + ) + .attribute_definitions( + AttributeDefinition::builder() + .attribute_name("pk") + .attribute_type(ScalarAttributeType::S) + .build() + .unwrap(), + ) + .attribute_definitions( + AttributeDefinition::builder() + .attribute_name("g") + .attribute_type(ScalarAttributeType::S) + .build() + .unwrap(), + ) + .global_secondary_indexes( + GlobalSecondaryIndex::builder() + .index_name("by-g") + .key_schema( + KeySchemaElement::builder() + .attribute_name("g") + .key_type(KeyType::Hash) + .build() + .unwrap(), + ) + .projection( + Projection::builder() + .projection_type(ProjectionType::All) + .build(), + ) + .build() + .unwrap(), + ) + .billing_mode(BillingMode::PayPerRequest) + .stream_specification( + StreamSpecification::builder() + .stream_enabled(true) + .stream_view_type(StreamViewType::NewImage) + .build() + .unwrap(), + ) + .send() + .await + .unwrap(); +} + +fn denied(result: Result) -> bool { + match result { + Ok(_) => false, + Err(e) => format!("{e:?}").contains("AccessDenied"), + } +} + +#[tokio::test] +async fn dynamodb_requires_a_policy_under_strict_enforcement() { + let server = start_strict().await; + create_table(&admin(&server).await, "Orders").await; + let nobody = user_with_policy( + &server, + "nobody", + &allow(&["sqs:ListQueues"], &["*".to_string()]), + ) + .await; + + assert!(denied(nobody.list_tables().send().await)); + assert!(denied( + nobody + .get_item() + .table_name("Orders") + .key("pk", AttributeValue::S("a".into())) + .send() + .await + )); +} + +/// A policy scoped to one action on one table allows exactly that. +#[tokio::test] +async fn item_actions_are_scoped_to_the_table_and_action() { + let server = start_strict().await; + let admin = admin(&server).await; + create_table(&admin, "Orders").await; + create_table(&admin, "Customers").await; + let reader = user_with_policy( + &server, + "reader", + &allow(&["dynamodb:GetItem"], &[table_arn("Orders")]), + ) + .await; + + reader + .get_item() + .table_name("Orders") + .key("pk", AttributeValue::S("a".into())) + .send() + .await + .expect("GetItem on the allowed table"); + assert!(denied( + reader + .put_item() + .table_name("Orders") + .item("pk", AttributeValue::S("a".into())) + .send() + .await + )); + assert!(denied( + reader + .get_item() + .table_name("Customers") + .key("pk", AttributeValue::S("a".into())) + .send() + .await + )); + // The table's ARN authorizes the same as its name. + reader + .get_item() + .table_name(table_arn("Orders")) + .key("pk", AttributeValue::S("a".into())) + .send() + .await + .expect("GetItem by table ARN"); +} + +/// A Query on an index is authorized against the index's ARN, not the +/// table's. +#[tokio::test] +async fn index_queries_are_authorized_against_the_index() { + let server = start_strict().await; + create_table(&admin(&server).await, "Orders").await; + let table_only = user_with_policy( + &server, + "table-only", + &allow(&["dynamodb:Query"], &[table_arn("Orders")]), + ) + .await; + let query_index = |client: DynamoClient| async move { + client + .query() + .table_name("Orders") + .index_name("by-g") + .key_condition_expression("g = :g") + .expression_attribute_values(":g", AttributeValue::S("x".into())) + .send() + .await + }; + assert!(denied(query_index(table_only).await)); + + let with_index = user_with_policy( + &server, + "with-index", + &allow( + &["dynamodb:Query"], + &[ + table_arn("Orders"), + format!("{}/index/*", table_arn("Orders")), + ], + ), + ) + .await; + query_index(with_index) + .await + .expect("Query on an allowed index"); +} + +/// A batch or transaction needs the permission on every table it touches: +/// being allowed on one of them is not enough. +#[tokio::test] +async fn batches_and_transactions_need_every_table() { + let server = start_strict().await; + let admin = admin(&server).await; + create_table(&admin, "Orders").await; + create_table(&admin, "Customers").await; + + let orders_only = user_with_policy( + &server, + "orders-only", + &allow( + &["dynamodb:PutItem", "dynamodb:BatchWriteItem"], + &[table_arn("Orders")], + ), + ) + .await; + let both = user_with_policy( + &server, + "both", + &allow( + &["dynamodb:PutItem", "dynamodb:BatchWriteItem"], + &[table_arn("Orders"), table_arn("Customers")], + ), + ) + .await; + + let put = |table: &str, pk: &str| { + TransactWriteItem::builder() + .put( + Put::builder() + .table_name(table) + .item("pk", AttributeValue::S(pk.into())) + .build() + .unwrap(), + ) + .build() + }; + assert!(denied( + orders_only + .transact_write_items() + .transact_items(put("Orders", "a")) + .transact_items(put("Customers", "a")) + .send() + .await + )); + both.transact_write_items() + .transact_items(put("Orders", "a")) + .transact_items(put("Customers", "a")) + .send() + .await + .expect("transaction allowed on both tables"); + + let write = |pk: &str| { + WriteRequest::builder() + .put_request( + PutRequest::builder() + .item("pk", AttributeValue::S(pk.into())) + .build() + .unwrap(), + ) + .build() + }; + assert!(denied( + orders_only + .batch_write_item() + .request_items("Orders", vec![write("b")]) + .request_items("Customers", vec![write("b")]) + .send() + .await + )); + orders_only + .batch_write_item() + .request_items("Orders", vec![write("b")]) + .send() + .await + .expect("batch on the allowed table only"); + // Nothing the denied requests carried was written. + let scan = admin.scan().table_name("Customers").send().await.unwrap(); + let pks: Vec<&str> = scan + .items() + .iter() + .map(|i| i["pk"].as_s().unwrap().as_str()) + .collect(); + assert_eq!(pks, ["a"]); +} + +/// PartiQL statements are authorized by their verb. +#[tokio::test] +async fn partiql_statements_need_their_partiql_action() { + let server = start_strict().await; + create_table(&admin(&server).await, "Orders").await; + let selector = user_with_policy( + &server, + "selector", + &allow(&["dynamodb:PartiQLSelect"], &[table_arn("Orders")]), + ) + .await; + + selector + .execute_statement() + .statement("SELECT * FROM \"Orders\"") + .send() + .await + .expect("SELECT allowed"); + assert!(denied( + selector + .execute_statement() + .statement("INSERT INTO \"Orders\" VALUE {'pk': 'a'}") + .send() + .await + )); + // The data-plane action does not grant the PartiQL one, or the reverse. + assert!(denied(selector.scan().table_name("Orders").send().await)); +} + +/// `aws:ResourceTag/*` conditions see the table's tags, and CreateTable with +/// tags also needs `dynamodb:TagResource`. +#[tokio::test] +async fn tag_conditions_and_create_table_tags() { + let server = start_strict().await; + let admin = admin(&server).await; + create_table(&admin, "Tagged").await; + create_table(&admin, "Untagged").await; + admin + .tag_resource() + .resource_arn(table_arn("Tagged")) + .tags( + Tag::builder() + .key("team") + .value("payments") + .build() + .unwrap(), + ) + .send() + .await + .unwrap(); + + let policy = serde_json::json!({ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": "dynamodb:GetItem", + "Resource": "*", + "Condition": {"StringEquals": {"aws:ResourceTag/team": "payments"}} + }, + { + "Effect": "Allow", + "Action": "dynamodb:CreateTable", + "Resource": "*" + } + ] + }) + .to_string(); + let payments = user_with_policy(&server, "payments", &policy).await; + let get = |client: &DynamoClient, table: &str| { + client + .get_item() + .table_name(table) + .key("pk", AttributeValue::S("a".into())) + .send() + }; + get(&payments, "Tagged").await.expect("tag matches"); + assert!(denied(get(&payments, "Untagged").await)); + + let create = |name: &str, tagged: bool| { + let mut req = payments + .create_table() + .table_name(name) + .key_schema( + KeySchemaElement::builder() + .attribute_name("pk") + .key_type(KeyType::Hash) + .build() + .unwrap(), + ) + .attribute_definitions( + AttributeDefinition::builder() + .attribute_name("pk") + .attribute_type(ScalarAttributeType::S) + .build() + .unwrap(), + ) + .billing_mode(BillingMode::PayPerRequest); + if tagged { + req = req.tags( + Tag::builder() + .key("team") + .value("payments") + .build() + .unwrap(), + ); + } + req.send() + }; + create("Plain", false).await.expect("CreateTable allowed"); + assert!( + denied(create("WithTags", true).await), + "CreateTable with Tags also needs dynamodb:TagResource" + ); +} + +/// DynamoDB Streams operations are authorized against the stream ARN. +#[tokio::test] +async fn streams_are_authorized_against_the_stream() { + let server = start_strict().await; + let admin = admin(&server).await; + create_table(&admin, "Orders").await; + let stream_arn = admin + .describe_table() + .table_name("Orders") + .send() + .await + .unwrap() + .table() + .unwrap() + .latest_stream_arn() + .unwrap() + .to_string(); + + let boot = sdk_config_with(&server, "test", "test").await; + let iam = IamClient::new(&boot); + iam.create_user() + .user_name("streamer") + .send() + .await + .unwrap(); + let key = iam + .create_access_key() + .user_name("streamer") + .send() + .await + .unwrap(); + let key = key.access_key().unwrap(); + iam.put_user_policy() + .user_name("streamer") + .policy_name("inline") + .policy_document(allow( + &["dynamodb:DescribeStream"], + std::slice::from_ref(&stream_arn), + )) + .send() + .await + .unwrap(); + let streams = aws_sdk_dynamodbstreams::Client::new( + &sdk_config_with(&server, key.access_key_id(), key.secret_access_key()).await, + ); + + let described = streams + .describe_stream() + .stream_arn(&stream_arn) + .send() + .await + .expect("DescribeStream allowed"); + let shard = described.stream_description().unwrap().shards()[0] + .shard_id() + .unwrap() + .to_string(); + assert!(denied( + streams + .get_shard_iterator() + .stream_arn(&stream_arn) + .shard_id(shard) + .shard_iterator_type(aws_sdk_dynamodbstreams::types::ShardIteratorType::TrimHorizon) + .send() + .await + )); + assert!(denied(streams.list_streams().send().await)); +} + +fn user_arn(name: &str) -> String { + format!("arn:aws:iam::{ACCOUNT}:user/{name}") +} + +async fn put_resource_policy(admin: &DynamoClient, arn: &str, policy: serde_json::Value) { + admin + .put_resource_policy() + .resource_arn(arn) + .policy(policy.to_string()) + .send() + .await + .unwrap(); +} + +/// Within the account, a table's resource policy can grant access on its +/// own, and an explicit Deny in it overrides an identity policy's Allow. +#[tokio::test] +async fn table_resource_policies_grant_and_deny() { + let server = start_strict().await; + let admin = admin(&server).await; + create_table(&admin, "Orders").await; + let granted = user_with_policy( + &server, + "granted", + &allow(&["sqs:ListQueues"], &["*".to_string()]), + ) + .await; + let denied_user = user_with_policy( + &server, + "denied", + &allow(&["dynamodb:*"], &["*".to_string()]), + ) + .await; + put_resource_policy( + &admin, + &table_arn("Orders"), + serde_json::json!({ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"AWS": user_arn("granted")}, + "Action": "dynamodb:GetItem", + "Resource": table_arn("Orders") + }, + { + "Effect": "Deny", + "Principal": {"AWS": user_arn("denied")}, + "Action": "dynamodb:GetItem", + "Resource": table_arn("Orders") + } + ] + }), + ) + .await; + + let get = |client: &DynamoClient| { + client + .get_item() + .table_name("Orders") + .key("pk", AttributeValue::S("a".into())) + .send() + }; + get(&granted) + .await + .expect("the table policy alone grants a same-account principal"); + assert!(denied( + granted + .put_item() + .table_name("Orders") + .item("pk", AttributeValue::S("a".into())) + .send() + .await + )); + assert!( + denied(get(&denied_user).await), + "an explicit Deny in the table policy beats the identity Allow" + ); + denied_user + .put_item() + .table_name("Orders") + .item("pk", AttributeValue::S("a".into())) + .send() + .await + .expect("the Deny covers GetItem only"); +} + +/// A stream's resource policy is its own: it grants stream reads the +/// table's policy does not. +#[tokio::test] +async fn stream_resource_policies_are_separate_from_the_table() { + let server = start_strict().await; + let admin = admin(&server).await; + create_table(&admin, "Orders").await; + let stream_arn = admin + .describe_table() + .table_name("Orders") + .send() + .await + .unwrap() + .table() + .unwrap() + .latest_stream_arn() + .unwrap() + .to_string(); + put_resource_policy( + &admin, + &stream_arn, + serde_json::json!({ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Principal": {"AWS": user_arn("reader")}, + "Action": "dynamodb:DescribeStream", + "Resource": stream_arn + }] + }), + ) + .await; + + let boot = sdk_config_with(&server, "test", "test").await; + let iam = IamClient::new(&boot); + iam.create_user().user_name("reader").send().await.unwrap(); + let key = iam + .create_access_key() + .user_name("reader") + .send() + .await + .unwrap(); + let key = key.access_key().unwrap(); + let cfg = sdk_config_with(&server, key.access_key_id(), key.secret_access_key()).await; + let streams = aws_sdk_dynamodbstreams::Client::new(&cfg); + + streams + .describe_stream() + .stream_arn(&stream_arn) + .send() + .await + .expect("the stream policy grants DescribeStream"); + assert!(denied( + DynamoClient::new(&cfg) + .describe_table() + .table_name("Orders") + .send() + .await + )); +} + +/// `dynamodb:LeadingKeys` limits a principal to its own partitions, and +/// `dynamodb:Attributes` / `dynamodb:Select` gate which attributes it reads. +#[tokio::test] +async fn fine_grained_access_by_partition_and_attribute() { + let server = start_strict().await; + let admin = admin(&server).await; + create_table(&admin, "Orders").await; + for pk in ["alice", "bob"] { + admin + .put_item() + .table_name("Orders") + .item("pk", AttributeValue::S(pk.into())) + .item("label", AttributeValue::S(pk.into())) + .item("ssn", AttributeValue::S("secret".into())) + .send() + .await + .unwrap(); + } + + let alice = user_with_policy( + &server, + "alice", + &serde_json::json!({ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": ["dynamodb:GetItem", "dynamodb:Query", "dynamodb:PutItem"], + "Resource": table_arn("Orders"), + "Condition": {"ForAllValues:StringEquals": {"dynamodb:LeadingKeys": ["alice"]}} + }, + { + "Effect": "Deny", + "Action": ["dynamodb:GetItem", "dynamodb:Query"], + "Resource": table_arn("Orders"), + "Condition": {"ForAnyValue:StringEquals": {"dynamodb:Attributes": ["ssn"]}} + }, + { + "Effect": "Deny", + "Action": ["dynamodb:GetItem", "dynamodb:Query"], + "Resource": table_arn("Orders"), + "Condition": {"StringNotEqualsIfExists": {"dynamodb:Select": "SPECIFIC_ATTRIBUTES"}} + } + ] + }) + .to_string(), + ) + .await; + + let get = |pk: &str, projection: Option<&str>| { + let mut req = alice + .get_item() + .table_name("Orders") + .key("pk", AttributeValue::S(pk.into())); + if let Some(p) = projection { + req = req.projection_expression(p); + } + req.send() + }; + let item = get("alice", Some("pk, label")) + .await + .expect("own partition, allowed attributes") + .item() + .cloned() + .unwrap(); + assert!(!item.contains_key("ssn")); + assert!( + denied(get("bob", Some("pk, label")).await), + "another partition" + ); + assert!( + denied(get("alice", Some("pk, ssn")).await), + "a denied attribute" + ); + assert!( + denied(get("alice", None).await), + "no projection reads every attribute" + ); + + let query = |pk: &str| { + alice + .query() + .table_name("Orders") + .key_condition_expression("pk = :p") + .expression_attribute_values(":p", AttributeValue::S(pk.into())) + .projection_expression("pk, label") + .send() + }; + query("alice").await.expect("query own partition"); + assert!(denied(query("bob").await)); + alice + .put_item() + .table_name("Orders") + .item("pk", AttributeValue::S("alice".into())) + .item("label", AttributeValue::S("A".into())) + .send() + .await + .expect("write own partition"); + assert!(denied( + alice + .put_item() + .table_name("Orders") + .item("pk", AttributeValue::S("bob".into())) + .send() + .await + )); +} diff --git a/crates/fakecloud-server/src/main.rs b/crates/fakecloud-server/src/main.rs index 8a6ed5915..e808eab0b 100644 --- a/crates/fakecloud-server/src/main.rs +++ b/crates/fakecloud-server/src/main.rs @@ -7239,6 +7239,9 @@ async fn main() { fakecloud_eventbridge::resource_policy::EventBridgeResourcePolicyProvider::shared( eb_state.clone(), ), + fakecloud_dynamodb::resource_policy::DynamoDbResourcePolicyProvider::shared( + dynamodb_state.clone(), + ), ], )), scp_resolver: Some( diff --git a/website/content/docs/reference/security.md b/website/content/docs/reference/security.md index ca2e68ace..19e711ac3 100644 --- a/website/content/docs/reference/security.md +++ b/website/content/docs/reference/security.md @@ -67,6 +67,8 @@ Opt-in enforcement covers the services most commonly subject to real IAM policie | **SNS** | All 34 supported actions | Topic / subscription / platform-app / endpoint ARNs | | **S3** | All 74 supported actions | `arn:aws:s3:::[/]` (object actions include the key; bucket actions don't) | | **KMS** | All 47 supported actions | `arn:aws:kms:::key/` (key-targeted actions) or `*` (account-level actions like CreateKey, ListKeys) | +| **DynamoDB** | All 58 supported operations | `arn:aws:dynamodb:::table/`, with `/index/` for `Query`, `Scan` and contributor insights on an index, `/backup/...`, `/export/...` and `/import/...` for those operations, `arn:aws:dynamodb:::global-table/` for legacy global tables, and `*` for account-level listings. Batches need the batch action on every table they name; transactions need `GetItem` / `PutItem` / `UpdateItem` / `DeleteItem` / `ConditionCheckItem` on each item's table; PartiQL statements need `PartiQLSelect` / `PartiQLInsert` / `PartiQLUpdate` / `PartiQLDelete`; `CreateTable` with `Tags` or `ResourcePolicy` also needs `TagResource` / `PutResourcePolicy`; restores also need the data-plane actions on the target table | +| **DynamoDB Streams** | All 4 supported operations (`dynamodb:` prefix) | `arn:aws:dynamodb:::table//stream/