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.
Two layers of isolation
Section titled “Two layers of isolation”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 actionIn a silo stack (step 09) the table belongs to a single tenant, so the function’s own role is used (IS_POOLED_DEPLOY=false).
- server/src/ProductService/product_service.py: handlers. Tenant id to X-Ray annotation, tenant-aware log and metric per call.
- server/src/ProductService/product_service_dal.py: data access, sharding, scoped-credential client.
- server/cdk/lib/tenant-template/microservice.ts: generic “table + 5 Lambdas + routes” construct. It grants the table to each function (read-only for GETs) and to
AuthorizerAccessRole. - server/cdk/lib/tenant-template/lambda-function.ts: shared function settings (ARM64 Python 3.13, tracing, layer,
livealias, error alarm, optional reserved concurrency for silo). - server/cdk/lib/tenant-template/services.ts: the business services of the stack.
- server/tests/test_product_service.py: tests with moto (in-memory DynamoDB).
- scripts/dev-isolation-check.sh: gets a user’s real scoped credentials by invoking the authorizer, then calls DynamoDB with them.
Two changes from the original:
- The original wires
PUTandDELETE /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.
Deploy
Section titled “Deploy”./scripts/deploy.sh you@example.comAs in step 04, new routes can return odd 403s (Invalid key=value pair ... in Authorization header) for about a minute after deploy. Retry.
Verify
Section titled “Verify”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-aB=$(./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}productscurl -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:
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 SecretUnit tests: cd server && .venv/bin/python -m pytest tests -q gives 30 passed.
Observability per tenant
Section titled “Observability per tenant”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 descStep 06 adds the order service the same way.