📋 In This Post
Lab Overview
A building has a locked door, and a guard who checks your badge against the room number before letting you through. It works. Then someone installs a faster side entrance, because the queue at the door was annoying. The side entrance has its own guard, and that guard was told only "staff may enter."
Nobody removed the badge check. It just stopped being on the route people take.
The problem. Multi-tenant systems pool tenants into
one DynamoDB table and keep them apart with an IAM condition:
dynamodb:LeadingKeys, which restricts a caller to its own
partition key. The control is real. But it is evaluated by DynamoDB, on
the request DynamoDB receives — and a cache in front of the table
changes who receives the request.
The objective. Build the pooled table and the condition, prove the isolation holds, then put a DAX cluster in front for latency and make the identical call again.
The outcome. tenant-alpha asking for
tenant-beta's item is refused with
AccessDeniedException going direct, and
returns tenant-beta's data through DAX. Same function,
same role, same table, same item. Two more documented traps in the same
policy behaved the same way. Ten checks, ten matches.
| Difficulty | Intermediate |
| Cost | $0.1202 measured from Cost Explorer — DAX $0.1200, DynamoDB $0.0002 |
| Deployment | Terraform via HCP Terraform with OIDC dynamic credentials |
| Region | us-east-1 — a DAX cluster can only read tables in its own Region |
| Full lab guide | GitHub — prerequisites, Terraform, commands, validation, cleanup |
Cloud Services and Concepts
| Service | Purpose here | What it is |
|---|---|---|
| Amazon DynamoDB | One pooled table holding both tenants, partitioned by tenant_id |
A managed key-value database with no servers to size. Each item sits under a partition key, and you are billed per read and write rather than per hour. |
| DynamoDB Accelerator (DAX) | The cache added for latency — and the thing that removes the isolation | An in-memory cache that speaks the DynamoDB API, so an application can adopt it by changing its client rather than its queries. It runs as a cluster of nodes inside your VPC. |
| AWS IAM | The isolation itself: a condition on the partition key the caller may touch | AWS's permission system. Policies say which actions a principal may take on which resources, and conditions narrow that further using values taken from the request. |
| AWS Lambda | Three functions running identical code, separated only by their role | Runs code on demand with no server to manage. Each function assumes an execution role, and that role is the identity AWS evaluates. |
| Amazon VPC | Required — DAX has no public endpoint, so the functions must live inside one | A private network inside your account. Resources placed in it are reachable from inside, not from the internet. |
| HCP Terraform | Remote applies using OIDC, so no AWS keys are stored anywhere | HashiCorp's hosted Terraform. It runs plans and applies on its own machines and keeps the state file. |
Pool or silo, and why anyone pools
You can give every tenant its own table — a silo — and isolation becomes a resource boundary rather than a policy. The limit is arithmetic: AWS allows 2,500 tables per account per Region, raised to a maximum of 10,000 on request, after which its own guidance is more accounts. A pooled table has no such ceiling, and pays for it by making isolation a property of a policy rather than of the furniture.
A condition that passes when nobody asks
Two of this week's three findings come from one sentence in AWS's
documentation: "A ForAllValues condition evaluates to
true when the condition key is not present in the request." A
Scan carries no partition key. So a policy that grants
Scan alongside a LeadingKeys condition reads as
though it restricts the caller to one tenant, and returns every tenant.
AWS's fix is a companion Null condition requiring the key to
be present at all.
Architecture and Workflow
Three Lambda functions run byte-identical code. What differs is the policy on each execution role:
alphaandbetacarry the policy AWS documents —LeadingKeysscoped to their own tenant, aNullcompanion,Selectpinned toSPECIFIC_ATTRIBUTES, and noScan.auditcarries the same policy with both guards removed andScanadded. It is not a straw man: it is what you get by writing the obvious policy and testing it withGetItem.
All three also hold dax:* read permissions on the cluster,
because that is what a team adds when read latency becomes a problem. That
grant is the whole story.
Prerequisites
| Requirement | Why | How to check |
|---|---|---|
| Terraform ≥ 1.10 | cloud {} block and AWS provider v6 | terraform version |
| HCP Terraform workspace, CLI-driven | The Lambda package is built locally and uploaded with the configuration; a VCS run would only have what is committed | terraform init |
| AWS permissions for DynamoDB, DAX, IAM, Lambda, VPC | The apply creates all five | aws sts get-caller-identity |
| Python 3.9+ with pip | Builds the package — the DAX client is not in the Lambda runtime | python --version |
| AWS CLI v2 | Teardown is verified against the API, not the destroy output | aws --version |
Implementation
What was deployed
| Resource | How many | Its job |
|---|---|---|
| DynamoDB table | 1 | Pooled, partitioned by tenant_id, on-demand billing |
| DAX cluster nodes | 3 | dax.t3.small, one per Availability Zone — three is the smallest fault-tolerant cluster |
| Lambda functions | 3 | alpha, beta, audit — identical code |
| IAM roles | 4 | One per function, plus the DAX cluster's own service role |
| VPC, subnets, route table | 1 / 3 / 1 | DAX is VPC-only, so the functions had to come inside |
| Gateway VPC endpoint | 1 | The direct path to DynamoDB once the functions lost their public route — free, unlike a NAT gateway |
Thirty-two resources in all, applied in about twelve minutes, nearly all of it the DAX cluster.
dax.t3.small, three nodes across three zones, and the
dax:// endpoint the functions connect to.The two policies, side by side
Everything separating the guarded policy from the one that leaks is these two condition blocks:
"Condition": {
"ForAllValues:StringEquals": {
"dynamodb:LeadingKeys": ["tenant-alpha"],
"dynamodb:Attributes": ["tenant_id","item_id","description","amount"]
},
"Null": { "dynamodb:LeadingKeys": "false" },
"StringEqualsIfExists": { "dynamodb:Select": "SPECIFIC_ATTRIBUTES" }
}
Remove the last two lines and add dynamodb:Scan to the
actions, and you have the audit role.
alpha role — not a snippet retyped for the article.
The account id is redacted; everything else is the live policy.Validation and Evidence
Ten calls across three principals and two paths. Each case declares what it expects before it runs, and the script exits non-zero if any case disagrees — three of them expect to be allowed something they should not be, and a script that merely printed what happened would read the same whether the finding reproduced or quietly stopped reproducing.
tenant_id
partition key. Nothing here separates them but a policy.| Test | Expected | Actual | Status |
|---|---|---|---|
| alpha reads its own item, direct | allowed | allowed | ✅ |
| alpha queries its own partition, direct | allowed | allowed | ✅ |
| alpha reads beta's item, direct | denied | AccessDeniedException | ✅ |
| alpha queries beta's partition, direct | denied | AccessDeniedException | ✅ |
| alpha reads beta's item through DAX | allowed | returned tenant-beta's data | ✅ |
| alpha queries beta's partition through DAX | allowed | returned tenant-beta's data | ✅ |
alpha runs Scan, direct | denied | AccessDeniedException | ✅ |
audit runs Scan, direct | allowed | both tenants returned | ✅ |
| alpha reads with no projection | denied | AccessDeniedException | ✅ |
| audit reads with no projection | allowed | returned secret_note | ✅ |
cross-tenant-direct denied, cross-tenant-dax
allowed and returning tenants=tenant-beta.secret_note is an attribute no policy's allow-list
contains. Its appearance in a response is the finding showing itself,
rather than a claim about what a policy says.
Security and Cost
What this means for a platform team
The sentence worth taking away: if tenant isolation rests on an IAM condition, putting a cache in front of the table is a security change, not a performance change. It will arrive as a latency ticket, be reviewed by whoever owns performance, and will not mention tenancy.
AWS says so plainly — users "inherit the permissions of the DAX cluster's IAM policy when they access that cluster… No other permissions are recognized", and the use of DAX "can subvert those policies." The documented mitigation is one cluster per isolation boundary, each with its own scoped policy, which prices isolation by the node-hour.
Two smaller controls, both worth copying: add a Null
condition beside every LeadingKeys condition, and pin
dynamodb:Select to SPECIFIC_ATTRIBUTES whenever
you restrict attributes — otherwise omitting a projection returns
everything.
Cost
| Line | Rate (us-east-1) |
|---|---|
DAX dax.t3.small | $0.04 per node-hour — three nodes, $0.12/hour |
| DynamoDB on-demand | $0.625 per million writes, $0.125 per million reads |
| Gateway VPC endpoint | Free — chosen over a NAT gateway at $0.045/hour |
The cluster existed for about fifty minutes. DAX bills per node-hour or partial hour, so fifty minutes is three node-hours, and no deploy-test-destroy cycle of this lab can cost less however fast you are.
Measured: $0.1202. Cost Explorer for 8 November reports $0.1200 against DynamoDB Accelerator and $0.0002 against DynamoDB — the five seeded items and a few hundred reads. The estimate above and the bill agree here, which is worth saying only because they often do not: a rate card tells you the price of a thing, not how many of them a lab turns out to use.
Left running, this is the first week in a while with a meter rather than a bill of cents: $2.88 a day, idle or not.
Troubleshooting and Lessons
Problem. The apply failed with
SubnetNotAllowedFault: Subnet … is not in a supported
availability zone.
Root cause. This account has a Local Zone enabled
(us-east-1-dfw-1a, Dallas), and
aws_availability_zones returns names sorted alphabetically.
us-east-1-dfw-1a sorts before us-east-1a,
because - precedes a. Taking "the first three
AZs" put a Local Zone at index 0.
Resolution. Filter the data source on
zone-type = availability-zone, which excludes Local Zones and
Wavelength Zones.
Lesson. "The first three availability zones" is a
sorting assumption wearing the costume of a requirement.
Problem. Terraform replaced all three Lambda functions
on every apply, with no code change.
Root cause. The package hashed differently each build.
pip writes console-script wrappers into bin/;
built on Windows those are .exe files that embed a path, and
each dist-info/RECORD stores a hash of them.
Resolution. Delete bin/, the
RECORD files and the __pycache__ left by the
import check, and write the zip with fixed member timestamps. The package
fell from 4.9 MB to 652 KB as a side effect.
Lesson. A Windows executable had been riding inside a
package destined for a Linux runtime, and the only symptom was a
needlessly churning plan.
Problem. The teardown script reported success without
checking anything.
Root cause. It queried a DAX cluster named
week26-tenant-isolation. DAX caps cluster_name
at 20 characters, so the cluster is actually week26-isolation.
The query asked about a name that never existed and reported "gone"
regardless — on the one resource billing by the hour.
Resolution. Query the real name. The false pass was
proved first, by running the old query against a cluster that was running
at the time and watching it report not-found.
Lesson. A safety check that cannot fail is worse than no
check, because it is load-bearing in your confidence and nowhere else.
Cleanup and Key Takeaways
Teardown was confirmed against the API rather than the destroy output — a destroy reports what Terraform removed, not what is left in the account. The DAX cluster, the subnet group, the table, the three functions and the VPC were all checked by name, and Terraform's state was confirmed empty.
A cache moves the authorisation boundary
If isolation is an IAM condition, anything that answers on DynamoDB's behalf must enforce it too — and DAX does not. One cluster per boundary is the documented answer, and it has a price per hour.
A condition that is absent is a condition that passed
ForAllValues evaluates true when the key is missing.
Pair every LeadingKeys condition with a Null
check, or Scan walks through it.
Attribute limits are on the request, not the answer
Omit the ProjectionExpression and everything comes
back regardless of dynamodb:Attributes. Pin
Select to SPECIFIC_ATTRIBUTES.
Write the expected result before the run
Three of these ten checks expect a failure of isolation. Declaring each outcome up front is what makes the script able to report that the finding stopped reproducing, instead of narrating whatever happened.
Next week: week 27.
Comments