Skip to content

Product service: pooled data with tenant isolation

Add the first business microservice: products, with full CRUD. All pooled tenants share one DynamoDB table, and each tenant can only ever reach its own items. This is enforced twice: in code, and by IAM using the credentials from step 04.

Method Path Lambda handler
GET /products get_products
POST /products create_product
GET /products/{key} get_product
PUT /products/{key} update_product
DELETE /products/{key} delete_product

key is "<shardId>:<productId>", for example tenant-a-9:e040650e-.... As in the reference solution, every route is a separate Lambda function, so each can scale and be monitored on its own.

Data model: partition key carries the tenant

Section titled “Data model: partition key carries the tenant”
Attribute Example
shardId (partition key) tenant-a-9, i.e. <tenantId>-<random 1..9>
productId (sort key) uuid
sku, name, price, category

Why shard? If a big tenant wrote everything under a single partition key, that partition would get hot. Spreading each tenant over 9 keys spreads the load. The cost is that listing a tenant’s products needs 9 Query calls, which the service runs in parallel threads. Scan is never used, and the scoped credentials don’t allow it.

1. Application check. The client sends the key in the URL, so it could send someone else’s key. utils.validate_shard_belongs_to_tenant(tenantId, shardId) compares the shard’s owner with the tenantId the authorizer asserted (never anything from the request) and the service returns 403.

2. IAM check. In the pooled stack the service doesn’t use its own Lambda role for DynamoDB. It builds a boto3 client from the scoped credentials the authorizer put in requestContext.authorizer. Those credentials only allow keys matching tenant-a-*, so even if the application check had a bug, DynamoDB itself would refuse:

AccessDeniedException: ... assumed-role/...AuthorizerAccessRole.../tenant-tenant-a is not authorized to perform: dynamodb:GetItem ... because no session policy allows the dynamodb:GetItem action

In a silo stack (step 09) the table belongs to a single tenant, so the function’s own role is used (IS_POOLED_DEPLOY=false).

Two changes from the original:

  • The original wires PUT and DELETE /products/{id} to the create function, a copy-paste bug. Here each route gets its own handler.
  • Tables use on-demand billing instead of 5/5 provisioned capacity.
Terminal window
./scripts/deploy.sh you@example.com

As in step 04, new routes can return odd 403s (Invalid key=value pair ... in Authorization header) for about a minute after deploy. Retry.

Terminal window
API=$(aws cloudformation describe-stacks --stack-name serverless-saas-tenant-template-pooled \
--query "Stacks[0].Outputs[?OutputKey=='ApiGatewayUrl'].OutputValue" --output text)
A=$(./scripts/tenant-token.sh alice 'Passw0rd!Saas') # tenant-a
B=$(./scripts/tenant-token.sh bob 'Passw0rd!Saas') # tenant-b
curl -s -X POST -H "Authorization: Bearer $A" ${API}products -d '{"sku":"A-1","name":"Alice Widget","price":10.5,"category":"tools"}'
curl -s -X POST -H "Authorization: Bearer $B" ${API}products -d '{"sku":"B-1","name":"Bob Secret","price":99,"category":"secret"}'
curl -s -H "Authorization: Bearer $A" ${API}products
curl -s -H "Authorization: Bearer $A" ${API}products/<bob's key>

Results from the test deployment:

Call Result
alice lists products ['Alice Gadget', 'Alice Widget']
bob lists products ['Bob Secret']
alice GET / PUT / DELETE bob’s tenant-b-2:b35c... 403 User not authorized to perform this action (each)
alice PUT her own product 200, name updated
bob GET his product afterwards 200 Bob Secret (unchanged)

IAM-level check, bypassing the service code entirely:

Terminal window
TABLE=$(aws cloudformation describe-stacks --stack-name serverless-saas-tenant-template-pooled \
--query "Stacks[0].Outputs[?OutputKey=='ProductTableName'].OutputValue" --output text)
./scripts/dev-isolation-check.sh alice 'Passw0rd!Saas' $TABLE tenant-b-2 productId <bob's productId>
# AccessDeniedException ... because no session policy allows the dynamodb:GetItem action
./scripts/dev-isolation-check.sh bob 'Passw0rd!Saas' $TABLE tenant-b-2 productId <bob's productId>
# Bob Secret

Unit tests: cd server && .venv/bin/python -m pytest tests -q gives 30 passed.

Each call logs with tenant_id, emits an EMF metric (ProductCreated, ProductsRetrieved, …) with a tenant_id dimension, and annotates the X-Ray trace with TenantId. In CloudWatch Logs Insights:

fields @timestamp, message, tenant_id
| filter tenant_id = "tenant-a"
| sort @timestamp desc

Step 06 adds the order service the same way.