Data & logs

Read and write items in one DynamoDB table and its indexes

Item-level reads and writes on one DynamoDB table and its indexes without table administration, plus optional per-user partitions and stream reads.

Published · Updated .

The problem

Service roles often get dynamodb:* because it makes the first deployment work. Even scoped to a single table ARN, dynamodb:* covers much more than data access. It includes DeleteTable; UpdateTable, which can switch off deletion protection, change streams and remove indexes; UpdateContinuousBackups, which controls point-in-time recovery; ExportTableToPointInTime, which copies the table to S3; and PutResourcePolicy, which can grant other principals access. A bug or a stolen credential in the request path should be able to read and change orders, not destroy the table or ship it elsewhere.

This example gives an order service item-level access to the Orders table and its secondary indexes and nothing else. Two optional policies cover related needs: a Lambda consumer of the table's stream, and an app where each signed-in user may touch only their own partition.

The policies

Item reads and writes on Orders, queries on its indexes

IAM identity-based policy. Attach to: The runtime role of the service that owns the Orders table (not its deployment role)

Item reads and writes on Orders, queries on its indexes · IAM identity-based policyOpen in Inline Policy Generator
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ItemAccessOnOrders",
      "Effect": "Allow",
      "Action": [
        "dynamodb:GetItem",
        "dynamodb:BatchGetItem",
        "dynamodb:Query",
        "dynamodb:PutItem",
        "dynamodb:UpdateItem",
        "dynamodb:DeleteItem",
        "dynamodb:BatchWriteItem",
        "dynamodb:ConditionCheckItem"
      ],
      "Resource": "arn:aws:dynamodb:eu-west-1:111122223333:table/Orders"
    },
    {
      "Sid": "QueryOrdersIndexes",
      "Effect": "Allow",
      "Action": "dynamodb:Query",
      "Resource": "arn:aws:dynamodb:eu-west-1:111122223333:table/Orders/index/*"
    }
  ]
}

Statement by statement

  • ItemAccessOnOrders: single-item and batch operations on the table itself. DeleteItem removes one item and is a different action from DeleteTable, which is not granted. Transactions need no extra actions: DynamoDB authorizes each part of TransactWriteItems and TransactGetItems as the underlying PutItem, UpdateItem, DeleteItem or GetItem, and a ConditionCheck as ConditionCheckItem. Scan is left out because request-path code rarely needs to read the whole table.
  • QueryOrdersIndexes: Query with an IndexName is authorized against the index ARN, table/Orders/index/<name>, which the table ARN does not match. Writes always target the table, so the index statement grants Query only. Replace * with specific index names if some indexes project attributes this service should not see.

Optional: read the Orders stream from a Lambda consumer

IAM identity-based policy. Attach to: The execution role of the Lambda function that processes the table's stream (a separate role from the service above)

Optional: read the Orders stream from a Lambda consumer · IAM identity-based policyOpen in Inline Policy Generator
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ReadOrdersStream",
      "Effect": "Allow",
      "Action": [
        "dynamodb:DescribeStream",
        "dynamodb:GetRecords",
        "dynamodb:GetShardIterator"
      ],
      "Resource": "arn:aws:dynamodb:eu-west-1:111122223333:table/Orders/stream/*"
    },
    {
      "Sid": "ListStreams",
      "Effect": "Allow",
      "Action": "dynamodb:ListStreams",
      "Resource": "*"
    }
  ]
}

Statement by statement

  • ReadOrdersStream: the AWS managed policy AWSLambdaDynamoDBExecutionRole grants these actions on "*", which covers every stream in the account. Stream ARNs end in /stream/<label>, and the label changes if the stream is turned off and on again, so the * after stream/ is intentional.
  • ListStreams: the Service Authorization Reference lists no resource type for ListStreams, so it needs "*". It returns stream ARNs and table names, not records.

Optional: each user reaches only their own partition

IAM identity-based policy. Attach to: The authenticated role of an Amazon Cognito identity pool, for a table whose partition key holds the user's identity ID

Optional: each user reaches only their own partition · IAM identity-based policyOpen in Inline Policy Generator
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "OwnPartitionOnly",
      "Effect": "Allow",
      "Action": [
        "dynamodb:GetItem",
        "dynamodb:BatchGetItem",
        "dynamodb:Query",
        "dynamodb:PutItem",
        "dynamodb:UpdateItem",
        "dynamodb:DeleteItem",
        "dynamodb:BatchWriteItem"
      ],
      "Resource": "arn:aws:dynamodb:eu-west-1:111122223333:table/UserCarts",
      "Condition": {
        "ForAllValues:StringEquals": {
          "dynamodb:LeadingKeys": [
            "${cognito-identity.amazonaws.com:sub}"
          ]
        },
        "Null": {
          "dynamodb:LeadingKeys": "false"
        }
      }
    }
  ]
}

Statement by statement

  • OwnPartitionOnly: dynamodb:LeadingKeys holds the partition key values a request touches, and DynamoDB requires the ForAllValues modifier with it. Every key must equal the caller's Cognito identity ID, which IAM substitutes for the variable at request time. ForAllValues also matches an empty set, so the Null check requires the key to be present, as IAM recommends for ForAllValues in an Allow. Scan is absent on purpose: DynamoDB's documentation points out that Scan returns items regardless of leading keys.

Before you start

  • An Orders table in eu-west-1 of account 111122223333, and a role for the service that uses it.
  • For the stream policy, a stream enabled on the table. For the per-user policy, a Cognito identity pool and a table whose partition key stores the identity ID.
  • A separate deployment role for table administration, such as UpdateTable and backups, so the runtime role never needs it.

Deploy it step by step

  1. Find out which calls the service really makes. SDK helpers and ORMs sometimes make calls you did not write, such as DescribeTable on startup, Scan for a 'find all', or PartiQL ExecuteStatement, which has its own actions such as dynamodb:PartiQLSelect. CloudTrail records table-level calls such as DescribeTable as management events, but item-level calls appear only if you log DynamoDB data events. Add only what you can justify.
  2. Validate and attach the item policy. Save the first policy as policy.json, check it, and add it to the service role. If the role still has a broad DynamoDB policy, remove that in the same change through your usual review; an Allow anywhere on the role is enough to keep the old access.
    Commands (replace <placeholders>)
    aws accessanalyzer validate-policy --policy-type IDENTITY_POLICY --policy-document file://policy.json
    aws iam put-role-policy --role-name <orders-service-role> --policy-name orders-item-access --policy-document file://policy.json
  3. Turn on deletion protection from the deployment role. Least privilege on the runtime role does not protect the table from administrators or pipelines. Deletion protection makes DeleteTable fail until someone with UpdateTable switches it off again, which adds a deliberate step before a delete.
    Commands (replace <placeholders>)
    aws dynamodb update-table --table-name Orders --deletion-protection-enabled --region eu-west-1
    aws dynamodb describe-table --table-name Orders --query Table.DeletionProtectionEnabled --region eu-west-1

Verify it without changing anything

These checks only read state, simulate, or use dry runs, so they are safe to run in any account. Exact error wording varies by service and over time.

Read an item the service owns

Run as the service role, for example from a shell with that role's session. Replace pk with your table's partition key name and use a key that exists.

Commands (replace <placeholders>)
aws dynamodb get-item --table-name Orders --key '{"pk":{"S":"<existing-order-id>"}}' --region eu-west-1

Expected: The item as JSON, or an empty response if no item has that key. Either way the call was authorized; an AccessDeniedException here means the table ARN or Region in the policy does not match.

Show the denials with harmless calls

These calls only read, so nothing changes even if the policy is wrong. Never test a denial with delete-table or update-table: with the wrong credentials, they do exactly what they say.

Commands (replace <placeholders>)
aws dynamodb describe-table --table-name Orders --region eu-west-1
aws dynamodb get-item --table-name <another-table> --key '{"pk":{"S":"<any-id>"}}' --region eu-west-1

Expected: Both should fail with AccessDeniedException. The message typically names the caller's assumed-role ARN, the action (dynamodb:DescribeTable or dynamodb:GetItem) and the table ARN, and says no identity-based policy allows it. If describe-table succeeds, another policy on the role grants table-level actions.

Simulate the dangerous actions instead of running them

The simulator evaluates the policy without sending a request to DynamoDB. Use simulate-principal-policy with the role ARN to include every policy attached to the role.

Commands (replace <placeholders>)
aws iam simulate-custom-policy --policy-input-list file://policy.json --action-names dynamodb:DeleteTable dynamodb:UpdateTable dynamodb:ExportTableToPointInTime dynamodb:Scan --resource-arns arn:aws:dynamodb:eu-west-1:111122223333:table/Orders
aws iam simulate-custom-policy --policy-input-list file://policy.json --action-names dynamodb:Query --resource-arns arn:aws:dynamodb:eu-west-1:111122223333:table/Orders/index/<index-name>

Expected: implicitDeny for all four actions in the first call and allowed for Query on the index in the second. The simulator does not model resource-based policies on the table unless you pass one, so treat this as a check of the identity policy only.

Common mistakes and troubleshooting

Query on an index is denied

If only table/Orders is listed, Query with an IndexName fails with AccessDeniedException naming the index ARN. Add table/Orders/index/* or the specific index names.

PartiQL and transactions behave differently

PartiQL statements need the dynamodb:PartiQLSelect, PartiQLInsert, PartiQLUpdate and PartiQLDelete actions, which this policy does not grant. Transactions work the other way: each part is authorized as the underlying item action, so a TransactWriteItems with a condition check also needs ConditionCheckItem.

LeadingKeys written with the wrong operator

Using StringEquals instead of ForAllValues:StringEquals with dynamodb:LeadingKeys does not work as intended. DynamoDB treats the key as multivalued, because a batch can touch several partitions, and requires ForAllValues.

Attribute restrictions that leak

If you add dynamodb:Attributes conditions, DynamoDB warns that reads without a ProjectionExpression return all attributes, and writes with ReturnValues ALL_OLD or ALL_NEW can return hidden ones. Restrict dynamodb:Select and dynamodb:ReturnValues too.

Limitations and production considerations

  • Item-level write access can still destroy data one item at a time. Point-in-time recovery or backups, managed from the deployment role, are the recovery path; this policy deliberately cannot change them.
  • A resource-based policy on the table or stream can grant access to other principals independently of this role. Review it with get-resource-policy when you audit the table.
  • DynamoDB's documentation says fine-grained conditions such as LeadingKeys are not supported for restricting global table replication. Keep them off the service-linked roles that replicate global tables.

Official AWS documentation

Account IDs, bucket and organization names are AWS documentation placeholders. Test in a non-production account before use (see the methodology).