Tenant API, Lambda authorizer and usage plans
Put a REST API in front of the tenant stack. Every request goes through a Lambda authorizer that turns the caller’s JWT into:
- tenant context (
tenantId,userRole,tenantTier) passed to the backend, - the API key of the tenant’s tier, so API Gateway applies that tier’s throttling and quota,
- temporary AWS credentials that can only touch this tenant’s data, used by services from step 05 on.
A small GET /whoami endpoint shows what the authorizer produced.
Request flow
Section titled “Request flow”API Gateway caches the authorizer’s answer per token for 30 seconds, so the STS call isn’t made on every request. That’s why credentials are vended in the authorizer and not in each service.
Tenant isolation with a session policy
Section titled “Tenant isolation with a session policy”AuthorizerAccessRole is a role that only the authorizer can assume. Each business service grants it read/write on its own table (step 05). When the authorizer assumes it, it passes an inline session policy:
{ "Effect": "Allow", "Action": ["dynamodb:GetItem","dynamodb:PutItem","dynamodb:UpdateItem","dynamodb:DeleteItem","dynamodb:Query"], "Resource": "arn:aws:dynamodb:<region>:<account>:table/*", "Condition": { "ForAllValues:StringLike": { "dynamodb:LeadingKeys": ["tenant-a-*"] } } }The temporary credentials get the intersection of the role’s permissions and this session policy. So even if a service has a bug and asks DynamoDB for tenant-b-3, AWS rejects the call. Scan is left out on purpose, because a scan would read every tenant’s items.
Only TenantAdmin and TenantUser get a policy. Any other role, such as SystemAdmin or a missing role, is denied, never given broader access (auth_manager.py).
Tiers → API keys → usage plans
Section titled “Tiers → API keys → usage plans”| Tier | Stack | Rate / burst (req/s) | Quota / day |
|---|---|---|---|
| BASIC | pooled | 50 / 50 | 1,000 |
| STANDARD | pooled | 75 / 100 | 3,000 |
| PREMIUM | pooled | 100 / 200 | 5,000 |
| PLATINUM | silo | 300 / 300 | 10,000 |
The API has apiKeySourceType: AUTHORIZER and apiKeyRequired: true. Clients never send an API key. The authorizer returns usageIdentifierKey and API Gateway counts the request against that key’s plan. A pooled stack only knows the Basic/Standard/Premium keys, so a Platinum user’s token is rejected there. That user belongs on their silo stack (step 09).
The shared Lambda layer
Section titled “The shared Lambda layer”server/src/layers is deployed as one Lambda layer used by every function. Services just import logger, import utils and so on:
| Module | What it hides from services |
|---|---|
logger.py |
Powertools logger; log_with_tenant_context adds tenant_id to every log line |
metrics_manager.py |
CloudWatch EMF metrics with a tenant_id dimension |
auth_manager.py |
Roles, who may manage users, the tenant-scoped IAM policy |
utils.py |
Tiers, JSON responses with CORS, validate_shard_belongs_to_tenant |
idp_object_factory.py + cognito/ |
Identity provider abstraction (only Cognito is implemented) |
This is the reference solution’s “hide tenant details in a layer” idea: developers of the product service don’t write tenant plumbing.
- server/src/TenantAuthorizer/tenant_authorizer.py: the authorizer.
- server/src/layers/cognito/cognito_authorizer.py: JWT verification with PyJWT (
PyJWKClientcaches the pool’s keys). - server/src/WhoAmI/whoami.py: debug endpoint.
- server/cdk/lib/tenant-template/api-gateway.ts: REST API, token authorizer (30 s cache),
AuthorizerAccessRole, CORS on gateway errors. - server/cdk/lib/tenant-template/usage-plans.ts: per-tier plans.
- server/cdk/lib/tenant-template/tenant-template-stack.ts: layer, API key lookup from SSM,
/whoami, outputApiGatewayUrl. - server/tests: unit tests (no AWS needed).
Run the unit tests
Section titled “Run the unit tests”cd serverpython3 -m venv .venv && .venv/bin/pip install -r tests/requirements-test.txt.venv/bin/python -m pytest tests -q # 26 passedThey cover JWT validation (wrong key, expired, wrong audience/issuer, access token), the authorizer (tier → key, rejected roles and tiers) and the isolation helpers.
Deploy
Section titled “Deploy”./scripts/deploy.sh you@example.comOutput: serverless-saas-tenant-template-pooled.ApiGatewayUrl = https://<id>.execute-api.us-east-1.amazonaws.com/prod/
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)./scripts/dev-create-tenant-user.sh tenant-b PREMIUM TenantUser bob 'Passw0rd!Saas'./scripts/dev-create-tenant-user.sh tenant-x PLATINUM TenantAdmin pat 'Passw0rd!Saas'
curl -s -H "Authorization: Bearer $(./scripts/tenant-token.sh alice 'Passw0rd!Saas')" ${API}whoamiResults from the test deployment:
| Caller | Response |
|---|---|
| no token / garbage token / access token | 401 {"message":"Unauthorized"} |
| alice (tenant-a, BASIC, TenantAdmin) | 200 {"apiKeyId":"6bo0lteh78","scopedCredentialsReceived":true,"tenantId":"tenant-a","tenantTier":"BASIC","userName":"alice","userRole":"TenantAdmin"} |
| bob (tenant-b, PREMIUM, TenantUser) | 200 {"apiKeyId":"ct8pnfqj1l",...,"tenantId":"tenant-b","tenantTier":"PREMIUM",...} |
| pat (PLATINUM) on the pooled API | 401, and the authorizer logs No tenant id or no API key for tier PLATINUM |
apiKeyId matches the tier’s key id in SSM (apiKeyBasicTierKeyId=6bo0lteh78, apiKeyPremiumTierKeyId=ct8pnfqj1l).
Right after the first deploy, valid tokens may get
403 Forbiddenfor about a minute while the new usage plan ↔ API key links propagate. Just retry.
Tenant-aware logs in /aws/lambda/<WhoAmIFunction>:
{"level":"INFO","message":"whoami called","service":"WhoAmI","tenant_id":"tenant-b", ...}{"_aws":{"CloudWatchMetrics":[{"Namespace":"ServerlessSaaS","Dimensions":[["tenant_id","service"]],"Metrics":[{"Name":"WhoAmICalled"}]}]},"tenant_id":"tenant-b","WhoAmICalled":[1.0]}Step 05 adds the first real service, products, whose pooled DynamoDB table is protected by these scoped credentials.